feat: modrinth hosting - files tab refactor (#4912)

* feat: api-client module for content v0

* feat: delete unused components + modules + setting

* feat: xhr uploading

* feat: fs module -> api-client

* feat: migrate files.vue to use tanstack

* fix: mem leak + other issues

* fix: build

* feat: switch to monaco

* fix: go back to using ace, but improve preloading + theme

* fix: styling + dead attrs

* feat: match figma

* fix: padding

* feat: files-new for ui page structure

* feat: finalize files.vue

* fix: lint

* fix: qa

* fix: dep

* fix: lint

* fix: lockfile merge

* feat: icons on navtab

* fix: surface alternating on table

* fix: hover surface color

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-01-06 00:35:51 +00:00
committed by GitHub
parent 61d4a34f0f
commit 099011a177
89 changed files with 5863 additions and 2091 deletions

View File

@@ -1,10 +1,10 @@
import { $fetch, FetchError } from 'ofetch'
import { AbstractModrinthClient } from '../core/abstract-client'
import type { ModrinthApiError } from '../core/errors'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
/**
* Generic platform client using ofetch
@@ -24,7 +24,7 @@ import { GenericWebSocketClient } from './websocket-generic'
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class GenericModrinthClient extends AbstractModrinthClient {
export class GenericModrinthClient extends XHRUploadClient {
constructor(config: ClientConfig) {
super(config)

View File

@@ -1,11 +1,12 @@
import { FetchError } from 'ofetch'
import { AbstractModrinthClient } from '../core/abstract-client'
import type { ModrinthApiError } from '../core/errors'
import { ModrinthApiError } from '../core/errors'
import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/circuit-breaker'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import type { UploadHandle, UploadRequestOptions } from '../types/upload'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
/**
* Circuit breaker storage using Nuxt's useState
@@ -53,6 +54,8 @@ export interface NuxtClientConfig extends ClientConfig {
*
* This client is optimized for Nuxt applications and handles SSR/CSR automatically.
*
* Note: upload() is only available in browser context (CSR). It will throw during SSR.
*
* @example
* ```typescript
* // In a Nuxt composable
@@ -70,7 +73,7 @@ export interface NuxtClientConfig extends ClientConfig {
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class NuxtModrinthClient extends AbstractModrinthClient {
export class NuxtModrinthClient extends XHRUploadClient {
declare protected config: NuxtClientConfig
constructor(config: NuxtClientConfig) {
@@ -84,6 +87,20 @@ export class NuxtModrinthClient extends AbstractModrinthClient {
})
}
/**
* Upload a file with progress tracking
*
* Note: This method is only available in browser context (CSR).
* Calling during SSR will throw an error.
*/
upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T> {
// @ts-expect-error - import.meta is provided by Nuxt
if (import.meta.server) {
throw new ModrinthApiError('upload() is not supported during SSR')
}
return super.upload(path, options)
}
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
try {
// @ts-expect-error - $fetch is provided by Nuxt runtime

View File

@@ -1,8 +1,8 @@
import { AbstractModrinthClient } from '../core/abstract-client'
import type { ModrinthApiError } from '../core/errors'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
/**
* Tauri-specific configuration
@@ -20,7 +20,9 @@ interface HttpError extends Error {
/**
* Tauri platform client using Tauri v2 HTTP plugin
*
* Extends XHRUploadClient to provide upload with progress tracking.
*
* @example
* ```typescript
* import { getVersion } from '@tauri-apps/api/app'
@@ -36,7 +38,7 @@ interface HttpError extends Error {
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class TauriModrinthClient extends AbstractModrinthClient {
export class TauriModrinthClient extends XHRUploadClient {
declare protected config: TauriClientConfig
constructor(config: TauriClientConfig) {

View File

@@ -0,0 +1,142 @@
import { AbstractModrinthClient } from '../core/abstract-client'
import { ModrinthApiError } from '../core/errors'
import type { RequestContext } from '../types/request'
import type {
UploadHandle,
UploadMetadata,
UploadProgress,
UploadRequestOptions,
} from '../types/upload'
/**
* Abstract client with XHR-based upload implementation
*
* Platform-specific clients should extend this instead of AbstractModrinthClient
* to inherit the XHR upload implementation.
*/
export abstract class XHRUploadClient extends AbstractModrinthClient {
upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T> {
let baseUrl: string
if (options.api === 'labrinth') {
baseUrl = this.config.labrinthBaseUrl!
} else if (options.api === 'archon') {
baseUrl = this.config.archonBaseUrl!
} else {
baseUrl = options.api
}
const url = this.buildUrl(path, baseUrl, options.version)
const mergedOptions: UploadRequestOptions = {
retry: false, // default: don't retry uploads
...options,
headers: {
...this.buildDefaultHeaders(),
'Content-Type': 'application/octet-stream',
...options.headers,
},
}
const context = this.buildUploadContext(url, path, mergedOptions)
const progressCallbacks: Array<(p: UploadProgress) => void> = []
if (mergedOptions.onProgress) {
progressCallbacks.push(mergedOptions.onProgress)
}
const abortController = new AbortController()
if (mergedOptions.signal) {
mergedOptions.signal.addEventListener('abort', () => abortController.abort())
}
const handle: UploadHandle<T> = {
promise: this.executeUploadFeatureChain<T>(context, progressCallbacks, abortController)
.then(async (result) => {
await this.config.hooks?.onResponse?.(result, context)
return result
})
.catch(async (error) => {
const apiError = this.normalizeError(error, context)
await this.config.hooks?.onError?.(apiError, context)
throw apiError
}),
onProgress: (callback) => {
progressCallbacks.push(callback)
return handle
},
cancel: () => abortController.abort(),
}
return handle
}
protected executeXHRUpload<T>(
context: RequestContext,
progressCallbacks: Array<(p: UploadProgress) => void>,
abortController: AbortController,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest()
const metadata = context.metadata as UploadMetadata
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress: UploadProgress = {
loaded: e.loaded,
total: e.total,
progress: e.loaded / e.total,
}
progressCallbacks.forEach((cb) => cb(progress))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(xhr.response ? JSON.parse(xhr.response) : (undefined as T))
} catch {
resolve(undefined as T)
}
} else {
reject(this.createUploadError(xhr))
}
})
xhr.addEventListener('error', () => reject(new ModrinthApiError('Upload failed')))
xhr.addEventListener('abort', () => reject(new ModrinthApiError('Upload cancelled')))
// build URL with params (unlike $fetch, XHR doesn't handle params automatically)
let url = context.url
if (context.options.params) {
const queryString = new URLSearchParams(
Object.entries(context.options.params).map(([k, v]) => [k, String(v)]),
).toString()
url += (url.includes('?') ? '&' : '?') + queryString
}
xhr.open('POST', url)
// apply headers from context (features may have modified them)
for (const [key, value] of Object.entries(context.options.headers ?? {})) {
xhr.setRequestHeader(key, value)
}
xhr.send(metadata.file)
abortController.signal.addEventListener('abort', () => xhr.abort())
})
}
protected createUploadError(xhr: XMLHttpRequest): ModrinthApiError {
let responseData: unknown
try {
responseData = xhr.response ? JSON.parse(xhr.response) : undefined
} catch {
responseData = xhr.responseText
}
return this.createNormalizedError(
new Error(`Upload failed with status ${xhr.status}`),
xhr.status,
responseData,
)
}
}