|
class APIClient {
|
|
constructor(baseURL = '/', logger = null, perfMonitor = null, appState = null) {
|
|
this.baseURL = baseURL;
|
|
this.token = localStorage.getItem('token');
|
|
this.logger = logger;
|
|
this.perfMonitor = perfMonitor;
|
|
this.appState = appState;
|
|
this.activeRequests = 0;
|
|
}
|
|
|
|
setToken(token) {
|
|
this.token = token;
|
|
if (token) {
|
|
localStorage.setItem('token', token);
|
|
} else {
|
|
localStorage.removeItem('token');
|
|
}
|
|
}
|
|
|
|
getToken() {
|
|
return this.token;
|
|
}
|
|
|
|
async request(endpoint, options = {}) {
|
|
this.activeRequests++;
|
|
this.appState?.setState({ isLoading: true });
|
|
|
|
const startTime = performance.now();
|
|
const url = `${this.baseURL}${endpoint}`;
|
|
const method = options.method || 'GET';
|
|
|
|
try {
|
|
this.logger?.debug(`API ${method}: ${endpoint}`);
|
|
|
|
const headers = {
|
|
...options.headers,
|
|
};
|
|
|
|
if (this.token && !options.skipAuth) {
|
|
headers['Authorization'] = `Bearer ${this.token}`;
|
|
}
|
|
|
|
const config = {
|
|
...options,
|
|
headers,
|
|
};
|
|
|
|
if (config.body) {
|
|
if (config.body instanceof FormData) {
|
|
let hasFile = false;
|
|
for (let pair of config.body.entries()) {
|
|
if (pair[1] instanceof File) {
|
|
hasFile = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!hasFile) {
|
|
this.logger?.error('FormData without File objects not allowed - JSON only communication enforced');
|
|
throw new Error('FormData is only allowed for file uploads');
|
|
}
|
|
|
|
this.logger?.debug('File upload detected, allowing FormData');
|
|
} else if (typeof config.body === 'object') {
|
|
headers['Content-Type'] = 'application/json';
|
|
config.body = JSON.stringify(config.body);
|
|
this.logger?.debug('Request body serialized to JSON');
|
|
}
|
|
}
|
|
|
|
const response = await fetch(url, config);
|
|
const duration = performance.now() - startTime;
|
|
|
|
if (this.perfMonitor) {
|
|
this.perfMonitor._recordMetric('api-request', duration);
|
|
this.perfMonitor._recordMetric(`api-${method}`, duration);
|
|
}
|
|
|
|
if (response.status === 401) {
|
|
this.logger?.warn('Unauthorized request, clearing token');
|
|
this.setToken(null);
|
|
window.location.href = '/';
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let errorData;
|
|
try {
|
|
errorData = await response.json();
|
|
} catch (e) {
|
|
errorData = { message: 'Unknown error' };
|
|
}
|
|
const errorMessage = errorData.detail || errorData.message || 'Request failed';
|
|
|
|
this.logger?.error(`API ${method} ${endpoint} failed: ${errorMessage}`, {
|
|
status: response.status,
|
|
duration: `${duration.toFixed(2)}ms`
|
|
});
|
|
|
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
|
detail: { message: errorMessage, type: 'error' }
|
|
}));
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
this.logger?.debug(`API ${method} ${endpoint} success`, {
|
|
status: response.status,
|
|
duration: `${duration.toFixed(2)}ms`
|
|
});
|
|
|
|
if (response.status === 204) {
|
|
return null;
|
|
}
|
|
|
|
const contentType = response.headers.get('content-type');
|
|
if (!contentType || !contentType.includes('application/json')) {
|
|
this.logger?.error(`Invalid response content-type: ${contentType}. Expected application/json`);
|
|
throw new Error('Server returned non-JSON response');
|
|
}
|
|
|
|
return response.json();
|
|
} catch (error) {
|
|
this.logger?.error(`API ${method} ${endpoint} exception`, error);
|
|
throw error;
|
|
} finally {
|
|
this.activeRequests--;
|
|
if (this.activeRequests === 0) {
|
|
this.appState?.setState({ isLoading: false });
|
|
}
|
|
}
|
|
}
|
|
|
|
async register(username, email, password) {
|
|
this.logger?.info('Attempting registration', { username, email });
|
|
|
|
const data = await this.request('auth/register', {
|
|
method: 'POST',
|
|
body: { username, email, password },
|
|
skipAuth: true
|
|
});
|
|
|
|
if (!data || !data.access_token) {
|
|
this.logger?.error('Invalid registration response: missing access_token', data);
|
|
throw new Error('Invalid registration response');
|
|
}
|
|
|
|
this.logger?.info('Registration successful', { username });
|
|
this.setToken(data.access_token);
|
|
return data;
|
|
}
|
|
|
|
async login(username, password) {
|
|
this.logger?.info('Attempting login', { username });
|
|
|
|
const data = await this.request('auth/token', {
|
|
method: 'POST',
|
|
body: { username, password },
|
|
skipAuth: true
|
|
});
|
|
|
|
if (!data || !data.access_token) {
|
|
this.logger?.error('Invalid login response: missing access_token', data);
|
|
throw new Error('Invalid authentication response');
|
|
}
|
|
|
|
this.logger?.info('Login successful', { username });
|
|
this.setToken(data.access_token);
|
|
return data;
|
|
}
|
|
|
|
logout() {
|
|
this.setToken(null);
|
|
window.location.href = '/';
|
|
}
|
|
|
|
async getCurrentUser() {
|
|
return this.request('users/me');
|
|
}
|
|
|
|
async listFolders(parentId = null) {
|
|
const params = parentId ? `?parent_id=${parentId}` : '';
|
|
return this.request(`folders/${params}`);
|
|
}
|
|
|
|
async createFolder(name, parentId = null) {
|
|
return this.request('folders/', {
|
|
method: 'POST',
|
|
body: { name, parent_id: parentId }
|
|
});
|
|
}
|
|
|
|
async getFolderPath(folderId) {
|
|
return this.request(`folders/${folderId}/path`);
|
|
}
|
|
|
|
async deleteFolder(folderId) {
|
|
return this.request(`folders/${folderId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
}
|
|
|
|
async uploadFile(file, folderId = null) {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const endpoint = folderId ? `files/upload?folder_id=${folderId}` : 'files/upload';
|
|
return this.request(endpoint, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
}
|
|
|
|
async listFiles(folderId = null) {
|
|
const params = folderId ? `?folder_id=${folderId}` : '';
|
|
return this.request(`files/${params}`);
|
|
}
|
|
|
|
async downloadFile(fileId) {
|
|
const url = `${this.baseURL}files/download/${fileId}`;
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
'Authorization': `Bearer ${this.token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Download failed');
|
|
}
|
|
|
|
return response.blob();
|
|
}
|
|
|
|
async deleteFile(fileId) {
|
|
return this.request(`files/${fileId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
}
|
|
|
|
async moveFile(fileId, targetFolderId) {
|
|
return this.request(`files/${fileId}/move`, {
|
|
method: 'POST',
|
|
body: { target_folder_id: targetFolderId }
|
|
});
|
|
}
|
|
|
|
async renameFile(fileId, newName) {
|
|
return this.request(`files/${fileId}/rename`, {
|
|
method: 'POST',
|
|
body: { new_name: newName }
|
|
});
|
|
}
|
|
|
|
async copyFile(fileId, targetFolderId) {
|
|
return this.request(`files/${fileId}/copy`, {
|
|
method: 'POST',
|
|
body: { target_folder_id: targetFolderId }
|
|
});
|
|
}
|
|
|
|
async createShare(fileId = null, folderId = null, expiresAt = null, password = null, permissionLevel = 'viewer') {
|
|
return this.request('shares/', {
|
|
method: 'POST',
|
|
body: {
|
|
file_id: fileId,
|
|
folder_id: folderId,
|
|
expires_at: expiresAt,
|
|
password,
|
|
permission_level: permissionLevel
|
|
}
|
|
});
|
|
}
|
|
|
|
async deleteShare(shareId) {
|
|
return this.request(`shares/${shareId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
}
|
|
|
|
async searchFiles(query, filters = {}) {
|
|
const params = new URLSearchParams({ q: query, ...filters });
|
|
return this.request(`search/files?${params}`);
|
|
}
|
|
|
|
async searchFolders(query) {
|
|
return this.request(`search/folders?q=${encodeURIComponent(query)}`);
|
|
}
|
|
|
|
async getPhotos() {
|
|
return this.request('files/photos');
|
|
}
|
|
|
|
async getThumbnailUrl(fileId) {
|
|
return `${this.baseURL}files/thumbnail/${fileId}`;
|
|
}
|
|
|
|
async listDeletedFiles() {
|
|
return this.request('files/deleted');
|
|
}
|
|
|
|
async restoreFile(fileId) {
|
|
return this.request(`files/${fileId}/restore`, {
|
|
method: 'POST'
|
|
});
|
|
}
|
|
|
|
async listUsers() {
|
|
return this.request('admin/users');
|
|
}
|
|
|
|
async createUser(userData) {
|
|
return this.request('admin/users', {
|
|
method: 'POST',
|
|
body: userData
|
|
});
|
|
}
|
|
|
|
async updateUser(userId, userData) {
|
|
return this.request(`admin/users/${userId}`, {
|
|
method: 'PUT',
|
|
body: userData
|
|
});
|
|
}
|
|
|
|
async deleteUser(userId) {
|
|
return this.request(`admin/users/${userId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
}
|
|
|
|
async starFile(fileId) {
|
|
return this.request(`files/${fileId}/star`, {
|
|
method: 'POST'
|
|
});
|
|
}
|
|
|
|
async unstarFile(fileId) {
|
|
return this.request(`files/${fileId}/unstar`, {
|
|
method: 'POST'
|
|
});
|
|
}
|
|
|
|
async starFolder(folderId) {
|
|
return this.request(`folders/${folderId}/star`, {
|
|
method: 'POST'
|
|
});
|
|
}
|
|
|
|
async unstarFolder(folderId) {
|
|
return this.request(`folders/${folderId}/unstar`, {
|
|
method: 'POST'
|
|
});
|
|
}
|
|
|
|
async listStarredFiles() {
|
|
return this.request('starred/files');
|
|
}
|
|
|
|
async listStarredFolders() {
|
|
return this.request('starred/folders');
|
|
}
|
|
|
|
async listRecentFiles() {
|
|
return this.request('files/recent');
|
|
}
|
|
|
|
async listMyShares() {
|
|
return this.request('shares/my');
|
|
}
|
|
|
|
async updateShare(shareId, shareData) {
|
|
return this.request(`shares/${shareId}`, {
|
|
method: 'PUT',
|
|
body: shareData
|
|
});
|
|
}
|
|
|
|
async batchFileOperations(operation, fileIds, targetFolderId = null) {
|
|
const payload = { file_ids: fileIds, operation: operation };
|
|
if (targetFolderId !== null) {
|
|
payload.target_folder_id = targetFolderId;
|
|
}
|
|
return this.request('files/batch', {
|
|
method: 'POST',
|
|
body: payload
|
|
});
|
|
}
|
|
|
|
async batchFolderOperations(operation, folderIds, targetFolderId = null) {
|
|
const payload = { folder_ids: folderIds, operation: operation };
|
|
if (targetFolderId !== null) {
|
|
payload.target_folder_id = targetFolderId;
|
|
}
|
|
return this.request('folders/batch', {
|
|
method: 'POST',
|
|
body: payload
|
|
});
|
|
}
|
|
|
|
async updateFile(fileId, content) {
|
|
return this.request(`files/${fileId}/content`, {
|
|
method: 'PUT',
|
|
body: { content }
|
|
});
|
|
}
|
|
}
|
|
|
|
export { APIClient };
|
|
|
|
let _sharedInstance = null;
|
|
|
|
export function setSharedAPIInstance(instance) {
|
|
_sharedInstance = instance;
|
|
}
|
|
|
|
export const api = new Proxy({}, {
|
|
get(target, prop) {
|
|
if (!_sharedInstance) {
|
|
console.warn('API instance accessed before initialization. This may cause issues with logging and state management.');
|
|
_sharedInstance = new APIClient();
|
|
}
|
|
|
|
const instance = _sharedInstance;
|
|
const value = instance[prop];
|
|
|
|
if (typeof value === 'function') {
|
|
return value.bind(instance);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
});
|