Merge tag 'v0.10.24' into beta

This commit is contained in:
2025-12-29 01:57:40 +03:00
422 changed files with 20967 additions and 8663 deletions

View File

@@ -8,6 +8,7 @@
"fix": "eslint . --fix && prettier --write ."
},
"dependencies": {
"mitt": "^3.0.1",
"ofetch": "^1.4.1"
},
"devDependencies": {

View File

@@ -4,6 +4,7 @@ import type { ClientConfig } from '../types/client'
import type { RequestContext, RequestOptions } from '../types/request'
import type { AbstractFeature } from './abstract-feature'
import type { AbstractModule } from './abstract-module'
import type { AbstractWebSocketClient } from './abstract-websocket'
import { ModrinthApiError, ModrinthServerError } from './errors'
/**
@@ -24,7 +25,7 @@ export abstract class AbstractModrinthClient {
private _moduleNamespaces: Map<string, Record<string, AbstractModule>> = new Map()
public readonly labrinth!: InferredClientModules['labrinth']
public readonly archon!: InferredClientModules['archon']
public readonly archon!: InferredClientModules['archon'] & { sockets: AbstractWebSocketClient }
public readonly kyros!: InferredClientModules['kyros']
public readonly iso3166!: InferredClientModules['iso3166']
@@ -123,6 +124,11 @@ export abstract class AbstractModrinthClient {
},
}
const headers = mergedOptions.headers
if (headers && 'Content-Type' in headers && headers['Content-Type'] === '') {
delete headers['Content-Type']
}
const context = this.buildContext(url, path, mergedOptions)
try {

View File

@@ -0,0 +1,104 @@
import type mitt from 'mitt'
import type { Archon } from '../modules/archon/types'
export type WebSocketEventHandler<
E extends Archon.Websocket.v0.WSEvent = Archon.Websocket.v0.WSEvent,
> = (event: E) => void
export interface WebSocketConnection {
serverId: string
socket: WebSocket
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
isReconnecting: boolean
}
export interface WebSocketStatus {
connected: boolean
reconnecting: boolean
reconnectAttempts: number
}
type WSEventMap = {
[K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K
}
export abstract class AbstractWebSocketClient {
protected connections = new Map<string, WebSocketConnection>()
protected abstract emitter: ReturnType<typeof mitt<WSEventMap>>
protected readonly MAX_RECONNECT_ATTEMPTS = 10
protected readonly RECONNECT_BASE_DELAY = 1000
protected readonly RECONNECT_MAX_DELAY = 30000
constructor(
protected client: {
archon: {
servers_v0: {
getWebSocketAuth: (serverId: string) => Promise<Archon.Websocket.v0.WSAuth>
}
}
},
) {}
abstract connect(serverId: string, auth: Archon.Websocket.v0.WSAuth): Promise<void>
abstract disconnect(serverId: string): void
abstract disconnectAll(): void
abstract send(serverId: string, message: Archon.Websocket.v0.WSOutgoingMessage): void
async safeConnect(serverId: string, options?: { force?: boolean }): Promise<void> {
const status = this.getStatus(serverId)
if (status?.connected && !options?.force) {
return
}
if (status && !status.connected && !options?.force) {
return
}
if (options?.force && status) {
this.disconnect(serverId)
}
const auth = await this.client.archon.servers_v0.getWebSocketAuth(serverId)
await this.connect(serverId, auth)
}
on<E extends Archon.Websocket.v0.WSEventType>(
serverId: string,
eventType: E,
handler: WebSocketEventHandler<Extract<Archon.Websocket.v0.WSEvent, { event: E }>>,
): () => void {
const eventKey = `${serverId}:${eventType}` as keyof WSEventMap
this.emitter.on(eventKey, handler as () => void)
return () => {
this.emitter.off(eventKey, handler as () => void)
}
}
getStatus(serverId: string): WebSocketStatus | null {
const connection = this.connections.get(serverId)
if (!connection) return null
return {
connected: connection.socket.readyState === WebSocket.OPEN,
reconnecting: connection.isReconnecting,
reconnectAttempts: connection.reconnectAttempts,
}
}
protected getReconnectDelay(attempt: number): number {
const delay = Math.min(
this.RECONNECT_BASE_DELAY * Math.pow(2, attempt),
this.RECONNECT_MAX_DELAY,
)
return delay + Math.random() * 1000
}
}

View File

@@ -0,0 +1,18 @@
import { AbstractFeature } from '../core/abstract-feature'
import type { RequestContext } from '../types/request'
export const PANEL_VERSION = 1
export class PanelVersionFeature extends AbstractFeature {
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
context.options.headers = {
...context.options.headers,
'X-Panel-Version': String(PANEL_VERSION),
}
return next()
}
shouldApply(_: RequestContext): boolean {
return true
}
}

View File

@@ -1,5 +1,11 @@
export { AbstractModrinthClient } from './core/abstract-client'
export { AbstractFeature, type FeatureConfig } from './core/abstract-feature'
export {
AbstractWebSocketClient,
type WebSocketConnection,
type WebSocketEventHandler,
type WebSocketStatus,
} from './core/abstract-websocket'
export { ModrinthApiError, ModrinthServerError } from './core/errors'
export { type AuthConfig, AuthFeature } from './features/auth'
export {
@@ -9,6 +15,7 @@ export {
type CircuitBreakerStorage,
InMemoryCircuitBreakerStorage,
} from './features/circuit-breaker'
export { PANEL_VERSION, PanelVersionFeature } from './features/panel-version'
export { type BackoffStrategy, type RetryConfig, RetryFeature } from './features/retry'
export { type VerboseLoggingConfig, VerboseLoggingFeature } from './features/verbose-logging'
export type { InferredClientModules } from './modules'

View File

@@ -0,0 +1,95 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonBackupsV0Module extends AbstractModule {
public getModuleID(): string {
return 'archon_backups_v0'
}
/** GET /modrinth/v0/servers/:server_id/backups */
public async list(serverId: string): Promise<Archon.Backups.v1.Backup[]> {
return this.client.request<Archon.Backups.v1.Backup[]>(`/servers/${serverId}/backups`, {
api: 'archon',
version: 'modrinth/v0',
method: 'GET',
})
}
/** GET /modrinth/v0/servers/:server_id/backups/:backup_id */
public async get(serverId: string, backupId: string): Promise<Archon.Backups.v1.Backup> {
return this.client.request<Archon.Backups.v1.Backup>(
`/servers/${serverId}/backups/${backupId}`,
{ api: 'archon', version: 'modrinth/v0', method: 'GET' },
)
}
/** POST /modrinth/v0/servers/:server_id/backups */
public async create(
serverId: string,
request: Archon.Backups.v1.BackupRequest,
): Promise<Archon.Backups.v1.PostBackupResponse> {
return this.client.request<Archon.Backups.v1.PostBackupResponse>(
`/servers/${serverId}/backups`,
{ api: 'archon', version: 'modrinth/v0', method: 'POST', body: request },
)
}
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/restore */
public async restore(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/restore`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
})
}
/** DELETE /modrinth/v0/servers/:server_id/backups/:backup_id */
public async delete(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}`, {
api: 'archon',
version: 'modrinth/v0',
method: 'DELETE',
})
}
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/lock */
public async lock(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/lock`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
})
}
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/unlock */
public async unlock(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/unlock`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
})
}
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/retry */
public async retry(serverId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/retry`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
})
}
/** PATCH /modrinth/v0/servers/:server_id/backups/:backup_id */
public async rename(
serverId: string,
backupId: string,
request: Archon.Backups.v1.PatchBackup,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}`, {
api: 'archon',
version: 'modrinth/v0',
method: 'PATCH',
body: request,
})
}
}

View File

@@ -0,0 +1,132 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
/**
* Default world ID - Uuid::nil() which the backend treats as "first/active world"
* See: apps/archon/src/routes/v1/servers/worlds/mod.rs - world_id_nullish()
* TODO:
* - Make sure world ID is being passed before we ship worlds.
* - The schema will change when Backups v4 (routes stay as v1) so remember to do that.
*/
const DEFAULT_WORLD_ID: string = '00000000-0000-0000-0000-000000000000' as const
export class ArchonBackupsV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_backups_v1'
}
/** GET /v1/:server_id/worlds/:world_id/backups */
public async list(
serverId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<Archon.Backups.v1.Backup[]> {
return this.client.request<Archon.Backups.v1.Backup[]>(
`/${serverId}/worlds/${worldId}/backups`,
{ api: 'archon', version: 1, method: 'GET' },
)
}
/** GET /v1/:server_id/worlds/:world_id/backups/:backup_id */
public async get(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<Archon.Backups.v1.Backup> {
return this.client.request<Archon.Backups.v1.Backup>(
`/${serverId}/worlds/${worldId}/backups/${backupId}`,
{ api: 'archon', version: 1, method: 'GET' },
)
}
/** POST /v1/:server_id/worlds/:world_id/backups */
public async create(
serverId: string,
request: Archon.Backups.v1.BackupRequest,
worldId: string = DEFAULT_WORLD_ID,
): Promise<Archon.Backups.v1.PostBackupResponse> {
return this.client.request<Archon.Backups.v1.PostBackupResponse>(
`/${serverId}/worlds/${worldId}/backups`,
{ api: 'archon', version: 1, method: 'POST', body: request },
)
}
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/restore */
public async restore(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/restore`, {
api: 'archon',
version: 1,
method: 'POST',
})
}
/** DELETE /v1/:server_id/worlds/:world_id/backups/:backup_id */
public async delete(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}`, {
api: 'archon',
version: 1,
method: 'DELETE',
})
}
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/lock */
public async lock(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/lock`, {
api: 'archon',
version: 1,
method: 'POST',
})
}
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/unlock */
public async unlock(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/unlock`, {
api: 'archon',
version: 1,
method: 'POST',
})
}
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/retry */
public async retry(
serverId: string,
backupId: string,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/retry`, {
api: 'archon',
version: 1,
method: 'POST',
})
}
/** PATCH /v1/:server_id/worlds/:world_id/backups/:backup_id */
public async rename(
serverId: string,
backupId: string,
request: Archon.Backups.v1.PatchBackup,
worldId: string = DEFAULT_WORLD_ID,
): Promise<void> {
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}`, {
api: 'archon',
version: 1,
method: 'PATCH',
body: request,
})
}
}

View File

@@ -1,3 +1,5 @@
export * from './servers/types'
export * from './backups/v0'
export * from './backups/v1'
export * from './servers/v0'
export * from './servers/v1'
export * from './types'

View File

@@ -6,6 +6,18 @@ export class ArchonServersV0Module extends AbstractModule {
return 'archon_servers_v0'
}
/**
* Get a specific server by ID
* GET /modrinth/v0/servers/:id
*/
public async get(serverId: string): Promise<Archon.Servers.v0.Server> {
return this.client.request<Archon.Servers.v0.Server>(`/servers/${serverId}`, {
api: 'archon',
method: 'GET',
version: 'modrinth/v0',
})
}
/**
* Get list of servers for the authenticated user
* GET /modrinth/v0/servers
@@ -54,4 +66,16 @@ export class ArchonServersV0Module extends AbstractModule {
method: 'GET',
})
}
/**
* Get WebSocket authentication credentials for a server
* GET /modrinth/v0/servers/:id/ws
*/
public async getWebSocketAuth(serverId: string): Promise<Archon.Websocket.v0.WSAuth> {
return this.client.request<Archon.Websocket.v0.WSAuth>(`/servers/${serverId}/ws`, {
api: 'archon',
version: 'modrinth/v0',
method: 'GET',
})
}
}

View File

@@ -125,4 +125,187 @@ export namespace Archon {
}
}
}
export namespace Backups {
export namespace v1 {
export type BackupState = 'ongoing' | 'done' | 'failed' | 'cancelled' | 'unchanged'
export type BackupTask = 'file' | 'create' | 'restore'
export type BackupTaskProgress = {
progress: number // 0.0 to 1.0
state: BackupState
}
export type Backup = {
id: string
name: string
created_at: string
locked: boolean
automated: boolean
interrupted: boolean
ongoing: boolean
task?: {
file?: BackupTaskProgress
create?: BackupTaskProgress
restore?: BackupTaskProgress
}
// TODO: Uncomment when API supports these fields
// size?: number // bytes
// creator_id?: string // user ID, or 'auto' for automated backups
}
export type BackupRequest = {
name: string
}
export type PatchBackup = {
name?: string
}
export type PostBackupResponse = {
id: string
}
}
}
export namespace Websocket {
export namespace v0 {
export type WSAuth = {
url: string
token: string
}
export type BackupState = 'ongoing' | 'done' | 'failed' | 'cancelled' | 'unchanged'
export type BackupTask = 'file' | 'create' | 'restore'
export type WSBackupProgressEvent = {
event: 'backup-progress'
id: string
task: BackupTask
state: BackupState
progress: number
}
export type WSLogEvent = {
event: 'log'
stream: 'stdout' | 'stderr'
message: string
}
export type WSStatsEvent = {
event: 'stats'
cpu_percent: number
ram_usage_bytes: number
ram_total_bytes: number
storage_usage_bytes: number
storage_total_bytes: number
net_tx_bytes: number
net_rx_bytes: number
}
export type PowerState = 'running' | 'stopped' | 'starting' | 'stopping' | 'crashed'
export type WSPowerStateEvent = {
event: 'power-state'
state: PowerState
oom_killed?: boolean
exit_code?: number
}
export type WSAuthExpiringEvent = {
event: 'auth-expiring'
}
export type WSAuthIncorrectEvent = {
event: 'auth-incorrect'
}
export type WSAuthOkEvent = {
event: 'auth-ok'
}
export type WSInstallationResultEvent =
| WSInstallationResultOkEvent
| WSInstallationResultErrEvent
export type WSInstallationResultOkEvent = {
event: 'installation-result'
result: 'ok'
}
export type WSInstallationResultErrEvent = {
event: 'installation-result'
result: 'err'
reason?: string
}
export type WSUptimeEvent = {
event: 'uptime'
uptime: number
}
export type WSNewModEvent = {
event: 'new-mod'
project_id: string
version_id: string
}
export type FilesystemOpKind = 'unarchive'
export type FilesystemOpState =
| 'queued'
| 'ongoing'
| 'done'
| 'cancelled'
| 'failure-corrupted'
| 'failure-invalid-path'
export type FilesystemOperation = {
op: FilesystemOpKind
id: string
progress: number
bytes_processed: number
files_processed: number
state: FilesystemOpState
mime: string
current_file?: string
invalid_path?: string
src: string
started: string
}
export type WSFilesystemOpsEvent = {
event: 'filesystem-ops'
all: FilesystemOperation[]
}
// Outgoing messages (client -> server)
export type WSOutgoingMessage = WSAuthMessage | WSCommandMessage
export type WSAuthMessage = {
event: 'auth'
jwt: string
}
export type WSCommandMessage = {
event: 'command'
cmd: string
}
export type WSEvent =
| WSBackupProgressEvent
| WSLogEvent
| WSStatsEvent
| WSPowerStateEvent
| WSAuthExpiringEvent
| WSAuthIncorrectEvent
| WSAuthOkEvent
| WSInstallationResultEvent
| WSUptimeEvent
| WSNewModEvent
| WSFilesystemOpsEvent
export type WSEventType = WSEvent['event']
}
}
}

View File

@@ -1,13 +1,18 @@
import type { AbstractModrinthClient } from '../core/abstract-client'
import type { AbstractModule } from '../core/abstract-module'
import { ArchonBackupsV0Module } from './archon/backups/v0'
import { ArchonBackupsV1Module } from './archon/backups/v1'
import { ArchonServersV0Module } from './archon/servers/v0'
import { ArchonServersV1Module } from './archon/servers/v1'
import { ISO3166Module } from './iso3166'
import { KyrosFilesV0Module } from './kyros/files/v0'
import { LabrinthVersionsV3Module } from './labrinth'
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthProjectsV2Module } from './labrinth/projects/v2'
import { LabrinthProjectsV3Module } from './labrinth/projects/v3'
import { LabrinthStateModule } from './labrinth/state'
import { LabrinthTechReviewInternalModule } from './labrinth/tech-review/internal'
type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
@@ -21,14 +26,19 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
* TODO: Better way? Probably not
*/
export const MODULE_REGISTRY = {
archon_backups_v0: ArchonBackupsV0Module,
archon_backups_v1: ArchonBackupsV1Module,
archon_servers_v0: ArchonServersV0Module,
archon_servers_v1: ArchonServersV1Module,
iso3166_data: ISO3166Module,
kyros_files_v0: KyrosFilesV0Module,
labrinth_billing_internal: LabrinthBillingInternalModule,
labrinth_collections: LabrinthCollectionsModule,
labrinth_projects_v2: LabrinthProjectsV2Module,
labrinth_projects_v3: LabrinthProjectsV3Module,
labrinth_state: LabrinthStateModule,
labrinth_tech_review_internal: LabrinthTechReviewInternalModule,
labrinth_versions_v3: LabrinthVersionsV3Module,
} as const satisfies Record<string, ModuleConstructor>
export type ModuleID = keyof typeof MODULE_REGISTRY

View File

@@ -0,0 +1,128 @@
import { AbstractModule } from '../../core/abstract-module.js'
import type { Labrinth } from '../types'
export class LabrinthCollectionsModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_collections'
}
/**
* Get a collection by ID (v3)
*
* @param id - Collection ID
* @returns Promise resolving to the collection data
*
* @example
* ```typescript
* const collection = await client.labrinth.collections.get('AANobbMI')
* ```
*/
public async get(id: string): Promise<Labrinth.Collections.Collection> {
return this.client.request<Labrinth.Collections.Collection>(`/collection/${id}`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Get multiple collections by IDs (v3)
*
* @param ids - Array of collection IDs
* @returns Promise resolving to array of collections
*
* @example
* ```typescript
* const collections = await client.labrinth.collections.getMultiple(['AANobbMI', 'BBNoobMI'])
* ```
*/
public async getMultiple(ids: string[]): Promise<Labrinth.Collections.Collection[]> {
return this.client.request<Labrinth.Collections.Collection[]>(`/collections`, {
api: 'labrinth',
version: 3,
method: 'GET',
params: { ids: JSON.stringify(ids) },
})
}
/**
* Edit a collection (v3)
*
* @param id - Collection ID
* @param data - Collection update data
*
* @example
* ```typescript
* await client.labrinth.collections.edit('AANobbMI', {
* name: 'Updated name',
* description: 'Updated description',
* status: 'listed'
* })
* ```
*/
public async edit(id: string, data: Labrinth.Collections.EditCollectionRequest): Promise<void> {
return this.client.request(`/collection/${id}`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: data,
})
}
/**
* Delete a collection (v3)
*
* @param id - Collection ID
*
* @example
* ```typescript
* await client.labrinth.collections.delete('AANobbMI')
* ```
*/
public async delete(id: string): Promise<void> {
return this.client.request(`/collection/${id}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
/**
* Edit a collection icon (v3)
*
* @param id - Collection ID
* @param icon - Icon file
* @param ext - File extension (e.g., 'png', 'jpg')
*
* @example
* ```typescript
* await client.labrinth.collections.editIcon('AANobbMI', iconFile, 'png')
* ```
*/
public async editIcon(id: string, icon: Blob, ext: string): Promise<void> {
return this.client.request(`/collection/${id}/icon?ext=${ext}`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: icon,
})
}
/**
* Delete a collection icon (v3)
*
* @param id - Collection ID
*
* @example
* ```typescript
* await client.labrinth.collections.deleteIcon('AANobbMI')
* ```
*/
public async deleteIcon(id: string): Promise<void> {
return this.client.request(`/collection/${id}/icon`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}

View File

@@ -1,4 +1,7 @@
export * from './billing/internal'
export * from './collections'
export * from './projects/v2'
export * from './projects/v3'
export * from './state'
export * from './tech-review/internal'
export * from './versions/v3'

View File

@@ -68,7 +68,10 @@ export class LabrinthProjectsV2Module extends AbstractModule {
api: 'labrinth',
version: 2,
method: 'GET',
params: params as Record<string, unknown>,
params: {
...params,
facets: params.facets ? JSON.stringify(params.facets) : undefined,
},
})
}

View File

@@ -0,0 +1,124 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthTechReviewInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_tech_review_internal'
}
/**
* Search for projects awaiting technical review.
*
* Returns a flat list of file reports with associated project data, ownership
* information, and moderation threads provided as lookup maps.
*
* @param params - Search parameters including pagination, filters, and sorting
* @returns Response object containing reports array and lookup maps for projects, threads, and ownership
*
* @example
* ```typescript
* const response = await client.labrinth.tech_review_internal.searchProjects({
* limit: 20,
* page: 0,
* sort_by: 'created_asc',
* filter: {
* project_type: ['mod', 'modpack']
* }
* })
* // Access reports: response.reports
* // Access project by ID: response.projects[projectId]
* ```
*/
public async searchProjects(
params: Labrinth.TechReview.Internal.SearchProjectsRequest,
): Promise<Labrinth.TechReview.Internal.SearchResponse> {
return this.client.request<Labrinth.TechReview.Internal.SearchResponse>(
'/moderation/tech-review/search',
{
api: 'labrinth',
version: 'internal',
method: 'POST',
body: params,
},
)
}
/**
* Get detailed information about a specific file report.
*
* @param reportId - The Delphi report ID
* @returns Full report with all issues and details
*
* @example
* ```typescript
* const report = await client.labrinth.tech_review_internal.getReport('report-123')
* console.log(report.file_name, report.issues.length)
* ```
*/
public async getReport(reportId: string): Promise<Labrinth.TechReview.Internal.FileReport> {
return this.client.request<Labrinth.TechReview.Internal.FileReport>(
`/moderation/tech-review/report/${reportId}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
/**
* Get detailed information about a specific issue.
*
* @param issueId - The issue ID
* @returns Issue with all its details
*
* @example
* ```typescript
* const issue = await client.labrinth.tech_review_internal.getIssue('issue-123')
* console.log(issue.issue_type, issue.status)
* ```
*/
public async getIssue(issueId: string): Promise<Labrinth.TechReview.Internal.FileIssue> {
return this.client.request<Labrinth.TechReview.Internal.FileIssue>(
`/moderation/tech-review/issue/${issueId}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
/**
* Update the status of a technical review issue detail.
*
* Allows moderators to mark an individual issue detail as safe (false positive) or unsafe (malicious).
*
* @param detailId - The ID of the issue detail to update
* @param data - The verdict for the detail
* @returns Promise that resolves when the update is complete
*/
public async updateIssueDetail(
detailId: string,
data: Labrinth.TechReview.Internal.UpdateIssueRequest,
): Promise<void> {
return this.client.request<void>(`/moderation/tech-review/issue-detail/${detailId}`, {
api: 'labrinth',
version: 'internal',
method: 'PATCH',
body: data,
})
}
public async submitProject(
projectId: string,
data: Labrinth.TechReview.Internal.SubmitProjectRequest,
): Promise<void> {
return this.client.request<void>(`/moderation/tech-review/submit/${projectId}`, {
api: 'labrinth',
version: 'internal',
method: 'POST',
body: data,
})
}
}

View File

@@ -13,7 +13,7 @@ export namespace Labrinth {
price_id: string
interval: PriceDuration
status: SubscriptionStatus
created: string // ISO datetime string
created: string
metadata?: SubscriptionMetadata
}
@@ -40,8 +40,8 @@ export namespace Labrinth {
amount: number
currency_code: string
status: ChargeStatus
due: string // ISO datetime string
last_attempt: string | null // ISO datetime string
due: string
last_attempt: string | null
type: ChargeType
subscription_id: string | null
subscription_interval: PriceDuration | null
@@ -172,6 +172,7 @@ export namespace Labrinth {
| 'shader'
| 'plugin'
| 'datapack'
| 'project'
export type GalleryImage = {
url: string
@@ -264,7 +265,7 @@ export namespace Labrinth {
export type ProjectSearchParams = {
query?: string
facets?: string[][]
facets?: string[][] // in the format of [["categories:forge"],["versions:1.17.1"]]
filters?: string
index?: 'relevance' | 'downloads' | 'follows' | 'newest' | 'updated'
offset?: number
@@ -336,6 +337,10 @@ export namespace Labrinth {
monetization_status: v2.MonetizationStatus
side_types_migration_review_status: 'reviewed' | 'pending'
environment?: Environment[]
/**
* @deprecated Not recommended to use.
**/
[key: string]: unknown
}
@@ -361,6 +366,250 @@ export namespace Labrinth {
}
}
export namespace Versions {
export namespace v2 {
export type VersionType = 'release' | 'beta' | 'alpha'
export type VersionStatus =
| 'listed'
| 'archived'
| 'draft'
| 'unlisted'
| 'scheduled'
| 'unknown'
export type DependencyType = 'required' | 'optional' | 'incompatible' | 'embedded'
export type FileType = 'required-resource-pack' | 'optional-resource-pack' | 'unknown'
export type VersionFile = {
hashes: Record<string, string>
url: string
filename: string
primary: boolean
size: number
file_type?: FileType
}
export type Dependency = {
file_name?: string
dependency_type: DependencyType
} & (
| {
project_id: string
}
| {
version_id: string
project_id?: string
}
)
export type Version = {
id: string
project_id: string
author_id: string
featured: boolean
name: string
version_number: string
changelog: string
changelog_url?: string | null
date_published: string
downloads: number
version_type: VersionType
status: VersionStatus
requested_status?: VersionStatus | null
files: VersionFile[]
dependencies: Dependency[]
game_versions: string[]
loaders: string[]
}
}
// TODO: consolidate duplicated types between v2 and v3 versions
export namespace v3 {
export interface Dependency {
dependency_type: Labrinth.Versions.v2.DependencyType
project_id?: string
file_name?: string
version_id?: string
}
export interface GetProjectVersionsParams {
game_versions?: string[]
loaders?: string[]
}
export type VersionChannel = 'release' | 'beta' | 'alpha'
export type FileType =
| 'required-resource-pack'
| 'optional-resource-pack'
| 'sources-jar'
| 'dev-jar'
| 'javadoc-jar'
| 'signature'
| 'unknown'
export interface VersionFileHash {
sha512: string
sha1: string
}
interface VersionFile {
hashes: VersionFileHash
url: string
filename: string
primary: boolean
size: number
file_type?: FileType
}
export interface Version {
name: string
version_number: string
changelog?: string
dependencies: Dependency[]
game_versions: string[]
version_type: VersionChannel
loaders: string[]
featured: boolean
status: Labrinth.Versions.v2.VersionStatus
id: string
project_id: string
author_id: string
date_published: string
downloads: number
files: VersionFile[]
environment?: Labrinth.Projects.v3.Environment
mrpack_loaders?: string[]
}
export interface DraftVersionFile {
fileType?: FileType
file: File
}
export type DraftVersion = Omit<
Labrinth.Versions.v3.CreateVersionRequest,
'file_parts' | 'primary_file' | 'file_types'
> & {
existing_files?: VersionFile[]
version_id?: string
environment?: Labrinth.Projects.v3.Environment
}
export interface CreateVersionRequest {
name: string
version_number: string
changelog: string
dependencies?: Array<{
version_id?: string
project_id?: string
file_name?: string
dependency_type: Labrinth.Versions.v2.DependencyType
}>
game_versions: string[]
version_type: 'release' | 'beta' | 'alpha'
loaders: string[]
featured?: boolean
status?: 'listed' | 'archived' | 'draft' | 'unlisted' | 'scheduled' | 'unknown'
requested_status?: 'listed' | 'archived' | 'draft' | 'unlisted' | null
project_id: string
file_parts: string[]
primary_file?: string
file_types?: Record<string, Labrinth.Versions.v3.FileType | null>
environment?: Labrinth.Projects.v3.Environment
mrpack_loaders?: string[]
}
export type ModifyVersionRequest = Partial<
Omit<CreateVersionRequest, 'project_id' | 'file_parts' | 'primary_file' | 'file_types'>
> & {
file_types?: {
algorithm: string
hash: string
file_type: Labrinth.Versions.v3.FileType | null
}[]
}
export type AddFilesToVersionRequest = {
file_parts: string[]
file_types?: Record<string, Labrinth.Versions.v3.FileType | null>
}
}
}
export namespace Users {
namespace Common {
export type Role = 'developer' | 'moderator' | 'admin'
export type AuthProvider =
| 'github'
| 'discord'
| 'microsoft'
| 'gitlab'
| 'google'
| 'steam'
| 'paypal'
export type UserPayoutData = {
paypal_address?: string
paypal_country?: string
venmo_handle?: string
balance: number
}
}
export namespace v2 {
export type Role = Common.Role
export type AuthProvider = Common.AuthProvider
export type UserPayoutData = Common.UserPayoutData
export type User = {
id: string
username: string
name?: string
avatar_url?: string
bio?: string
created: string
role: Role
badges: number
auth_providers?: AuthProvider[]
email?: string
email_verified?: boolean
has_password?: boolean
has_totp?: boolean
payout_data?: UserPayoutData
github_id?: number
}
}
export namespace v3 {
export type Role = Common.Role
export type AuthProvider = Common.AuthProvider
export type UserPayoutData = Common.UserPayoutData
export type User = {
id: string
username: string
avatar_url?: string
bio?: string
created: string
role: Role
badges: number
auth_providers?: AuthProvider[]
email?: string
email_verified?: boolean
has_password?: boolean
has_totp?: boolean
payout_data?: UserPayoutData
stripe_customer_id?: string
allow_friend_requests?: boolean
github_id?: number
}
}
}
export namespace Tags {
export namespace v2 {
export interface Category {
@@ -379,7 +628,7 @@ export namespace Labrinth {
export interface GameVersion {
version: string
version_type: string
date: string // RFC 3339 DateTime
date: string
major: boolean
}
@@ -425,6 +674,30 @@ export namespace Labrinth {
}
}
export namespace Collections {
export type CollectionStatus = 'listed' | 'unlisted' | 'private' | 'rejected' | 'unknown'
export type Collection = {
id: string
user: string
name: string
description: string | null
icon_url: string | null
color: number | null
status: CollectionStatus
created: string
updated: string
projects: string[]
}
export type EditCollectionRequest = {
name?: string
description?: string | null
status?: CollectionStatus
new_projects?: string[]
}
}
export namespace State {
export interface GeneratedState {
categories: Tags.v2.Category[]
@@ -450,4 +723,181 @@ export namespace Labrinth {
errors: unknown[]
}
}
export namespace TechReview {
export namespace Internal {
export type SearchProjectsRequest = {
limit?: number
page?: number
filter?: SearchProjectsFilter
sort_by?: SearchProjectsSort
}
export type SearchProjectsFilter = {
project_type?: string[]
}
export type SearchProjectsSort =
| 'created_asc'
| 'created_desc'
| 'severity_asc'
| 'severity_desc'
export type UpdateIssueRequest = {
verdict: 'safe' | 'unsafe'
}
export type SubmitProjectRequest = {
verdict: 'safe' | 'unsafe'
message?: string
}
export type SearchResponse = {
project_reports: ProjectReport[]
projects: Record<string, ProjectModerationInfo>
threads: Record<string, Thread>
ownership: Record<string, Ownership>
}
export type ProjectModerationInfo = {
id: string
thread_id: string
name: string
project_types: string[]
icon_url: string | null
} & Projects.v3.Project
export type ProjectReport = {
project_id: string
max_severity: DelphiSeverity | null
versions: VersionReport[]
}
export type VersionReport = {
version_id: string
files: FileReport[]
}
export type FileReport = {
report_id: string
file_id: string
created: string
flag_reason: FlagReason
severity: DelphiSeverity
file_name: string
file_size: number
download_url: string
issues: FileIssue[]
}
export type FileIssue = {
id: string
report_id: string
issue_type: string
details: ReportIssueDetail[]
}
export type ReportIssueDetail = {
id: string
issue_id: string
key: string
file_path: string
decompiled_source: string | null
data: Record<string, unknown>
severity: DelphiSeverity
status: DelphiReportIssueStatus
}
export type Ownership =
| {
kind: 'user'
id: string
name: string
icon_url?: string
}
| {
kind: 'organization'
id: string
name: string
icon_url?: string
}
export type DBThread = {
id: string
project_id?: string
report_id?: string
type_: ThreadType
messages: DBThreadMessage[]
members: string[]
}
export type DBThreadMessage = {
id: string
thread_id: string
author_id?: string
body: MessageBody
created: string
hide_identity: boolean
}
export type MessageBody =
| {
type: 'text'
body: string
private?: boolean
replying_to?: string
associated_images?: string[]
}
| {
type: 'status_change'
new_status: Projects.v2.ProjectStatus
old_status: Projects.v2.ProjectStatus
}
| {
type: 'thread_closure'
}
| {
type: 'thread_reopen'
}
| {
type: 'deleted'
private?: boolean
}
export type ThreadType = 'report' | 'project' | 'direct_message'
export type User = {
id: string
username: string
avatar_url: string
role: string
badges: number
created: string
bio?: string
}
export type ThreadMessage = {
id: string | null
author_id: string | null
body: MessageBody
created: string
hide_identity: boolean
}
export type Thread = {
id: string
type: ThreadType
project_id: string | null
report_id: string | null
messages: ThreadMessage[]
members: User[]
}
export type FlagReason = 'delphi'
export type DelphiSeverity = 'low' | 'medium' | 'high' | 'severe'
export type DelphiReportIssueStatus = 'pending' | 'safe' | 'unsafe'
}
}
}

View File

@@ -0,0 +1,290 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthVersionsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_versions_v3'
}
/**
* Get versions for a project (v3)
*
* @param id - Project ID or slug (e.g., 'sodium' or 'AANobbMI')
* @param options - Optional query parameters to filter versions
* @returns Promise resolving to an array of v3 versions
*
* @example
* ```typescript
* const versions = await client.labrinth.versions_v3.getProjectVersions('sodium')
* const filteredVersions = await client.labrinth.versions_v3.getProjectVersions('sodium', {
* game_versions: ['1.20.1'],
* loaders: ['fabric']
* })
* console.log(versions[0].version_number)
* ```
*/
public async getProjectVersions(
id: string,
options?: Labrinth.Versions.v3.GetProjectVersionsParams,
): Promise<Labrinth.Versions.v3.Version[]> {
const params: Record<string, string> = {}
if (options?.game_versions?.length) {
params.game_versions = JSON.stringify(options.game_versions)
}
if (options?.loaders?.length) {
params.loaders = JSON.stringify(options.loaders)
}
return this.client.request<Labrinth.Versions.v3.Version[]>(`/project/${id}/version`, {
api: 'labrinth',
version: 2, // TODO: move this to a versions v2 module to keep api-client clean and organized
method: 'GET',
params: Object.keys(params).length > 0 ? params : undefined,
})
}
/**
* Get a specific version by ID (v3)
*
* @param id - Version ID
* @returns Promise resolving to the v3 version data
*
* @example
* ```typescript
* const version = await client.labrinth.versions_v3.getVersion('DXtmvS8i')
* console.log(version.version_number)
* ```
*/
public async getVersion(id: string): Promise<Labrinth.Versions.v3.Version> {
return this.client.request<Labrinth.Versions.v3.Version>(`/version/${id}`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Get multiple versions by IDs (v3)
*
* @param ids - Array of version IDs
* @returns Promise resolving to an array of v3 versions
*
* @example
* ```typescript
* const versions = await client.labrinth.versions_v3.getVersions(['DXtmvS8i', 'abc123'])
* console.log(versions[0].version_number)
* ```
*/
public async getVersions(ids: string[]): Promise<Labrinth.Versions.v3.Version[]> {
return this.client.request<Labrinth.Versions.v3.Version[]>(`/versions`, {
api: 'labrinth',
version: 3,
method: 'GET',
params: { ids: JSON.stringify(ids) },
})
}
/**
* Get a version from a project by version ID or number (v3)
*
* @param projectId - Project ID or slug
* @param versionId - Version ID or version number
* @returns Promise resolving to the v3 version data
*
* @example
* ```typescript
* const version = await client.labrinth.versions_v3.getVersionFromIdOrNumber('sodium', 'DXtmvS8i')
* const versionByNumber = await client.labrinth.versions_v3.getVersionFromIdOrNumber('sodium', '0.4.12')
* ```
*/
public async getVersionFromIdOrNumber(
projectId: string,
versionId: string,
): Promise<Labrinth.Versions.v3.Version> {
return this.client.request<Labrinth.Versions.v3.Version>(
`/project/${projectId}/version/${versionId}`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Create a new version for a project (v3)
*
* Creates a new version on an existing project. At least one file must be
* attached unless the version is created as a draft.
*
* @param data - JSON metadata payload for the version (must include file_parts)
* @param files - Array of uploaded files, in the same order as `data.file_parts`
*
* @returns A promise resolving to the newly created version data
*
* @example
* ```ts
* const version = await client.labrinth.versions_v3.createVersion('sodium', {
* name: 'v0.5.0',
* version_number: '0.5.0',
* version_type: 'release',
* loaders: ['fabric'],
* game_versions: ['1.20.1'],
* project_id: 'sodium',
* file_parts: ['primary']
* }, [fileObject])
* ```
*/
public async createVersion(
draftVersion: Labrinth.Versions.v3.DraftVersion,
versionFiles: Labrinth.Versions.v3.DraftVersionFile[],
projectType: Labrinth.Projects.v2.ProjectType | null = null,
): Promise<Labrinth.Versions.v3.Version> {
const formData = new FormData()
const files = versionFiles.map((vf) => vf.file)
const fileTypes = versionFiles.map((vf) => vf.fileType || null)
const fileParts = files.map((file, i) => {
return `${file.name}-${i === 0 ? 'primary' : i}`
})
const fileTypeMap = fileParts.reduce<Record<string, Labrinth.Versions.v3.FileType | null>>(
(acc, key, i) => {
acc[key] = fileTypes[i]
return acc
},
{},
)
const data: Labrinth.Versions.v3.CreateVersionRequest = {
project_id: draftVersion.project_id,
version_number: draftVersion.version_number,
name: draftVersion.name || draftVersion.version_number,
changelog: draftVersion.changelog,
dependencies: draftVersion.dependencies || [],
game_versions: draftVersion.game_versions,
version_type: draftVersion.version_type,
featured: !!draftVersion.featured,
file_parts: fileParts,
file_types: fileTypeMap,
primary_file: fileParts[0],
environment: draftVersion.environment,
loaders: draftVersion.loaders,
}
if (projectType === 'modpack') {
data.mrpack_loaders = draftVersion.loaders
data.loaders = ['mrpack']
}
formData.append('data', JSON.stringify(data))
files.forEach((file, i) => {
formData.append(fileParts[i], new Blob([file]), file.name)
})
const newVersion = await this.client.request<Labrinth.Versions.v3.Version>(`/version`, {
api: 'labrinth',
version: 3,
method: 'POST',
body: formData,
timeout: 60 * 5 * 1000,
headers: {
'Content-Type': '',
},
})
return newVersion
}
/**
* Modify an existing version by ID (v3)
*
* Partially updates a versions metadata. Only JSON fields may be modified.
* To update files, use the separate "Add files to version" endpoint.
*
* @param versionId - The version ID to update
* @param data - PATCH metadata for this version (all fields optional)
*
* @returns A promise resolving to the updated version data
*
* @example
* ```ts
* const updated = await client.labrinth.versions_v3.modifyVersion('DXtmvS8i', {
* name: 'v1.0.1',
* changelog: 'Updated changelog',
* featured: true,
* status: 'listed'
* })
* ```
*/
public async modifyVersion(
versionId: string,
data: Labrinth.Versions.v3.ModifyVersionRequest,
): Promise<Labrinth.Versions.v3.Version> {
return this.client.request<Labrinth.Versions.v3.Version>(`/version/${versionId}`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: data,
})
}
/**
* Delete a version by ID (v3)
*
* @param versionId - Version ID
*
* @example
* ```typescript
* await client.labrinth.versions_v3.deleteVersion('DXtmvS8i')
* ```
*/
public async deleteVersion(versionId: string): Promise<void> {
return this.client.request(`/version/${versionId}`, {
api: 'labrinth',
version: 2,
method: 'DELETE',
})
}
public async addFilesToVersion(
versionId: string,
versionFiles: Labrinth.Versions.v3.DraftVersionFile[],
): Promise<Labrinth.Versions.v3.Version> {
const formData = new FormData()
const files = versionFiles.map((vf) => vf.file)
const fileTypes = versionFiles.map((vf) => vf.fileType || null)
const fileParts = files.map((file, i) => `${file.name}-${i}`)
const fileTypeMap = fileParts.reduce<Record<string, Labrinth.Versions.v3.FileType | null>>(
(acc, key, i) => {
acc[key] = fileTypes[i]
return acc
},
{},
)
formData.append('data', JSON.stringify({ file_types: fileTypeMap }))
files.forEach((file, i) => {
formData.append(fileParts[i], new Blob([file]), file.name)
})
return this.client.request<Labrinth.Versions.v3.Version>(`/version/${versionId}/file`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: formData,
timeout: 60 * 5 * 1000,
headers: {
'Content-Type': '',
},
})
}
}

View File

@@ -2,7 +2,9 @@ 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'
/**
* Generic platform client using ofetch
@@ -23,6 +25,17 @@ import type { RequestOptions } from '../types/request'
* ```
*/
export class GenericModrinthClient extends AbstractModrinthClient {
constructor(config: ClientConfig) {
super(config)
Object.defineProperty(this.archon, 'sockets', {
value: new GenericWebSocketClient(this),
writable: false,
enumerable: true,
configurable: false,
})
}
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
try {
const response = await $fetch<T>(url, {

View File

@@ -5,6 +5,7 @@ import type { 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 { GenericWebSocketClient } from './websocket-generic'
/**
* Circuit breaker storage using Nuxt's useState
@@ -72,6 +73,17 @@ export interface NuxtClientConfig extends ClientConfig {
export class NuxtModrinthClient extends AbstractModrinthClient {
protected declare config: NuxtClientConfig
constructor(config: NuxtClientConfig) {
super(config)
Object.defineProperty(this.archon, 'sockets', {
value: new GenericWebSocketClient(this),
writable: false,
enumerable: true,
configurable: false,
})
}
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
try {
// @ts-expect-error - $fetch is provided by Nuxt runtime

View File

@@ -2,6 +2,7 @@ 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'
/**
* Tauri-specific configuration
@@ -38,6 +39,17 @@ interface HttpError extends Error {
export class TauriModrinthClient extends AbstractModrinthClient {
protected declare config: TauriClientConfig
constructor(config: TauriClientConfig) {
super(config)
Object.defineProperty(this.archon, 'sockets', {
value: new GenericWebSocketClient(this),
writable: false,
enumerable: true,
configurable: false,
})
}
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
try {
// Dynamically import Tauri HTTP plugin

View File

@@ -0,0 +1,149 @@
import mitt from 'mitt'
import { AbstractWebSocketClient, type WebSocketConnection } from '../core/abstract-websocket'
import type { Archon } from '../modules/archon/types'
type WSEventMap = {
[K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K
}
const NORMAL_CLOSURE = 1000
export class GenericWebSocketClient extends AbstractWebSocketClient {
protected emitter = mitt<WSEventMap>()
async connect(serverId: string, auth: Archon.Websocket.v0.WSAuth): Promise<void> {
if (this.connections.has(serverId)) {
this.disconnect(serverId)
}
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(`wss://${auth.url}`)
const connection: WebSocketConnection = {
serverId,
socket: ws,
reconnectAttempts: 0,
reconnectTimer: undefined,
isReconnecting: false,
}
this.connections.set(serverId, connection)
ws.onopen = () => {
ws.send(JSON.stringify({ event: 'auth', jwt: auth.token }))
connection.reconnectAttempts = 0
connection.isReconnecting = false
resolve()
}
ws.onmessage = (messageEvent) => {
try {
const data = JSON.parse(messageEvent.data) as Archon.Websocket.v0.WSEvent
const eventKey = `${serverId}:${data.event}` as keyof WSEventMap
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.emitter.emit(eventKey, data as any)
if (data.event === 'auth-expiring' || data.event === 'auth-incorrect') {
this.handleAuthExpiring(serverId).catch(console.error)
}
} catch (error) {
console.error('[WebSocket] Failed to parse message:', error)
}
}
ws.onclose = (event) => {
if (event.code !== NORMAL_CLOSURE) {
this.scheduleReconnect(serverId, auth)
}
}
ws.onerror = (error) => {
console.error(`[WebSocket] Error for server ${serverId}:`, error)
reject(new Error(`WebSocket connection failed for server ${serverId}`))
}
} catch (error) {
reject(error)
}
})
}
disconnect(serverId: string): void {
const connection = this.connections.get(serverId)
if (!connection) return
if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer)
connection.reconnectTimer = undefined
}
if (
connection.socket.readyState === WebSocket.OPEN ||
connection.socket.readyState === WebSocket.CONNECTING
) {
connection.socket.close(NORMAL_CLOSURE, 'Client disconnecting')
}
this.emitter.all.forEach((_handlers, type) => {
if (type.toString().startsWith(`${serverId}:`)) {
this.emitter.all.delete(type)
}
})
this.connections.delete(serverId)
}
disconnectAll(): void {
for (const serverId of this.connections.keys()) {
this.disconnect(serverId)
}
}
send(serverId: string, message: Archon.Websocket.v0.WSOutgoingMessage): void {
const connection = this.connections.get(serverId)
if (!connection || connection.socket.readyState !== WebSocket.OPEN) {
console.warn(`Cannot send message: WebSocket not connected for server ${serverId}`)
return
}
connection.socket.send(JSON.stringify(message))
}
private scheduleReconnect(serverId: string, auth: Archon.Websocket.v0.WSAuth): void {
const connection = this.connections.get(serverId)
if (!connection) return
if (connection.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
this.disconnect(serverId)
return
}
connection.isReconnecting = true
connection.reconnectAttempts++
const delay = this.getReconnectDelay(connection.reconnectAttempts)
connection.reconnectTimer = setTimeout(() => {
this.connect(serverId, auth).catch((error) => {
console.error(`[WebSocket] Reconnection failed for server ${serverId}:`, error)
})
}, delay)
}
private async handleAuthExpiring(serverId: string): Promise<void> {
try {
const newAuth = await this.client.archon.servers_v0.getWebSocketAuth(serverId)
const connection = this.connections.get(serverId)
if (connection && connection.socket.readyState === WebSocket.OPEN) {
connection.socket.send(JSON.stringify({ event: 'auth', jwt: newAuth.token }))
}
} catch (error) {
console.error(`[WebSocket] Failed to refresh auth for server ${serverId}:`, error)
this.disconnect(serverId)
}
}
}

View File

@@ -6,6 +6,7 @@ export type {
CircuitBreakerStorage,
} from '../features/circuit-breaker'
export type { BackoffStrategy, RetryConfig } from '../features/retry'
export type { Archon } from '../modules/archon/types'
export type { ClientConfig, RequestHooks } from './client'
export type { ApiErrorData, ModrinthErrorResponse } from './errors'
export { isModrinthErrorResponse } from './errors'

View File

@@ -452,35 +452,64 @@ where
Ok(())
}
pub fn get_processor_arguments<T: AsRef<str>>(
pub fn get_processor_arguments(
libraries_path: &Path,
arguments: &[T],
arguments: &[impl AsRef<str>],
data: &HashMap<String, SidedDataEntry>,
) -> crate::Result<Vec<String>> {
let mut new_arguments = Vec::new();
// We use iterator combinators to make sure that 1 input argument maps
// to exactly 1 output argument. Otherwise you might get issues that take
// days to debug *sigh*
//
// Arguments can be enclosed in square brackets [] if they are not taken
// literally, but are actually resolved to the path of a library we
// previously downloaded.
// For example, `[net.neoforged:neoform:1.21.10-20251010.172816:mappings@tsrg.lzma]`.
//
// Otherwise, arguments may contain `{KEY}` variable placeholders, which
// must be replaced with the corresponding value from `data`.
// Importantly, variables might not *just* be `{KEY}`, but may also be
// e.g. `{KEY}/some more values`. For example, `{ROOT}/libraries/`.
// Therefore, it is important that we don't just check if the variable is
// enclosed in `{}`s, but actually do a find-and-replace with all variables.
//
// Currently, we do it in a naive way where we iterate over every `data`
// entry and just `.replace()`, which is not efficient, but we shouldn't
// have a lot of entries in `data`, and this code is not run often anyway.
for argument in arguments {
let trimmed_arg = &argument.as_ref()[1..argument.as_ref().len() - 1];
if argument.as_ref().starts_with('{') {
if let Some(entry) = data.get(trimmed_arg) {
new_arguments.push(if entry.client.starts_with('[') {
get_lib_path(
libraries_path,
&entry.client[1..entry.client.len() - 1],
true,
)?
} else {
entry.client.clone()
})
arguments
.iter()
.map(|arg| {
let arg = arg.as_ref();
if let Some(arg) = arg.strip_prefix('[')
&& let Some(lib_key) = arg.strip_suffix(']')
{
// this should resolve to the path of a library
get_lib_path(libraries_path, lib_key, true)
} else {
let mut arg = arg.to_string();
// replace variables like `{PATH}` to their real values
for (key, entry) in data {
let replacement = if let Some(arg) =
entry.client.strip_prefix('[')
&& let Some(lib_key) = arg.strip_suffix(']')
{
// if the value of `PATH` in `data` is also a library key,
// it'll be enclosed in `[]`s, and we resolve it to a real lib path
get_lib_path(libraries_path, lib_key, true)?
} else {
// otherwise we just take the value in `data` literally
entry.client.clone()
};
arg = arg.replace(&format!("{{{key}}}"), &replacement);
}
Ok(arg)
}
} else if argument.as_ref().starts_with('[') {
new_arguments.push(get_lib_path(libraries_path, trimmed_arg, true)?)
} else {
new_arguments.push(argument.as_ref().to_string())
}
}
Ok(new_arguments)
})
.collect::<crate::Result<Vec<_>>>()
}
pub async fn get_processor_main_class(

View File

@@ -336,6 +336,11 @@ pub struct VersionFile {
pub enum FileType {
RequiredResourcePack,
OptionalResourcePack,
SourcesJar,
DevJar,
JavadocJar,
Signature,
#[serde(other)]
Unknown,
}

View File

@@ -28,6 +28,7 @@ import _BoxIcon from './icons/box.svg?component'
import _BoxImportIcon from './icons/box-import.svg?component'
import _BracesIcon from './icons/braces.svg?component'
import _BrushCleaningIcon from './icons/brush-cleaning.svg?component'
import _BugIcon from './icons/bug.svg?component'
import _CalendarIcon from './icons/calendar.svg?component'
import _CardIcon from './icons/card.svg?component'
import _ChangeSkinIcon from './icons/change-skin.svg?component'
@@ -35,6 +36,7 @@ import _ChartIcon from './icons/chart.svg?component'
import _CheckIcon from './icons/check.svg?component'
import _CheckCheckIcon from './icons/check-check.svg?component'
import _CheckCircleIcon from './icons/check-circle.svg?component'
import _ChevronDownIcon from './icons/chevron-down.svg?component'
import _ChevronLeftIcon from './icons/chevron-left.svg?component'
import _ChevronRightIcon from './icons/chevron-right.svg?component'
import _CircleUserIcon from './icons/circle-user.svg?component'
@@ -69,12 +71,16 @@ import _EyeIcon from './icons/eye.svg?component'
import _EyeOffIcon from './icons/eye-off.svg?component'
import _FileIcon from './icons/file.svg?component'
import _FileArchiveIcon from './icons/file-archive.svg?component'
import _FileCodeIcon from './icons/file-code.svg?component'
import _FileImageIcon from './icons/file-image.svg?component'
import _FileTextIcon from './icons/file-text.svg?component'
import _FilterIcon from './icons/filter.svg?component'
import _FilterXIcon from './icons/filter-x.svg?component'
import _FolderIcon from './icons/folder.svg?component'
import _FolderArchiveIcon from './icons/folder-archive.svg?component'
import _FolderOpenIcon from './icons/folder-open.svg?component'
import _FolderSearchIcon from './icons/folder-search.svg?component'
import _FolderUpIcon from './icons/folder-up.svg?component'
import _GameIcon from './icons/game.svg?component'
import _GapIcon from './icons/gap.svg?component'
import _GaugeIcon from './icons/gauge.svg?component'
@@ -93,6 +99,7 @@ import _Heading2Icon from './icons/heading-2.svg?component'
import _Heading3Icon from './icons/heading-3.svg?component'
import _HeartIcon from './icons/heart.svg?component'
import _HeartHandshakeIcon from './icons/heart-handshake.svg?component'
import _HeartMinusIcon from './icons/heart-minus.svg?component'
import _HistoryIcon from './icons/history.svg?component'
import _HomeIcon from './icons/home.svg?component'
import _ImageIcon from './icons/image.svg?component'
@@ -112,6 +119,7 @@ import _LinkIcon from './icons/link.svg?component'
import _ListIcon from './icons/list.svg?component'
import _ListBulletedIcon from './icons/list-bulleted.svg?component'
import _ListEndIcon from './icons/list-end.svg?component'
import _ListFilterIcon from './icons/list-filter.svg?component'
import _ListOrderedIcon from './icons/list-ordered.svg?component'
import _LoaderIcon from './icons/loader.svg?component'
import _LoaderCircleIcon from './icons/loader-circle.svg?component'
@@ -168,6 +176,8 @@ import _ServerPlusIcon from './icons/server-plus.svg?component'
import _SettingsIcon from './icons/settings.svg?component'
import _ShareIcon from './icons/share.svg?component'
import _ShieldIcon from './icons/shield.svg?component'
import _ShieldAlertIcon from './icons/shield-alert.svg?component'
import _ShieldCheckIcon from './icons/shield-check.svg?component'
import _SignalIcon from './icons/signal.svg?component'
import _SkullIcon from './icons/skull.svg?component'
import _SlashIcon from './icons/slash.svg?component'
@@ -203,6 +213,7 @@ import _UploadIcon from './icons/upload.svg?component'
import _UserIcon from './icons/user.svg?component'
import _UserCogIcon from './icons/user-cog.svg?component'
import _UserPlusIcon from './icons/user-plus.svg?component'
import _UserRoundIcon from './icons/user-round.svg?component'
import _UserSearchIcon from './icons/user-search.svg?component'
import _UserXIcon from './icons/user-x.svg?component'
import _UsersIcon from './icons/users.svg?component'
@@ -243,6 +254,7 @@ export const BoxImportIcon = _BoxImportIcon
export const BoxIcon = _BoxIcon
export const BracesIcon = _BracesIcon
export const BrushCleaningIcon = _BrushCleaningIcon
export const BugIcon = _BugIcon
export const CalendarIcon = _CalendarIcon
export const CardIcon = _CardIcon
export const ChangeSkinIcon = _ChangeSkinIcon
@@ -250,6 +262,7 @@ export const ChartIcon = _ChartIcon
export const CheckCheckIcon = _CheckCheckIcon
export const CheckCircleIcon = _CheckCircleIcon
export const CheckIcon = _CheckIcon
export const ChevronDownIcon = _ChevronDownIcon
export const ChevronLeftIcon = _ChevronLeftIcon
export const ChevronRightIcon = _ChevronRightIcon
export const CircleUserIcon = _CircleUserIcon
@@ -283,6 +296,8 @@ export const ExternalIcon = _ExternalIcon
export const EyeOffIcon = _EyeOffIcon
export const EyeIcon = _EyeIcon
export const FileArchiveIcon = _FileArchiveIcon
export const FileCodeIcon = _FileCodeIcon
export const FileImageIcon = _FileImageIcon
export const FileTextIcon = _FileTextIcon
export const FileIcon = _FileIcon
export const FilterXIcon = _FilterXIcon
@@ -290,6 +305,8 @@ export const FilterIcon = _FilterIcon
export const FolderArchiveIcon = _FolderArchiveIcon
export const FolderOpenIcon = _FolderOpenIcon
export const FolderSearchIcon = _FolderSearchIcon
export const FolderIcon = _FolderIcon
export const FolderUpIcon = _FolderUpIcon
export const GameIcon = _GameIcon
export const GapIcon = _GapIcon
export const GaugeIcon = _GaugeIcon
@@ -307,6 +324,7 @@ export const Heading1Icon = _Heading1Icon
export const Heading2Icon = _Heading2Icon
export const Heading3Icon = _Heading3Icon
export const HeartHandshakeIcon = _HeartHandshakeIcon
export const HeartMinusIcon = _HeartMinusIcon
export const HeartIcon = _HeartIcon
export const HistoryIcon = _HistoryIcon
export const HomeIcon = _HomeIcon
@@ -326,6 +344,7 @@ export const LightBulbIcon = _LightBulbIcon
export const LinkIcon = _LinkIcon
export const ListBulletedIcon = _ListBulletedIcon
export const ListEndIcon = _ListEndIcon
export const ListFilterIcon = _ListFilterIcon
export const ListOrderedIcon = _ListOrderedIcon
export const ListIcon = _ListIcon
export const LoaderCircleIcon = _LoaderCircleIcon
@@ -382,6 +401,8 @@ export const ServerPlusIcon = _ServerPlusIcon
export const ServerIcon = _ServerIcon
export const SettingsIcon = _SettingsIcon
export const ShareIcon = _ShareIcon
export const ShieldAlertIcon = _ShieldAlertIcon
export const ShieldCheckIcon = _ShieldCheckIcon
export const ShieldIcon = _ShieldIcon
export const SignalIcon = _SignalIcon
export const SkullIcon = _SkullIcon
@@ -417,6 +438,7 @@ export const UpdatedIcon = _UpdatedIcon
export const UploadIcon = _UploadIcon
export const UserCogIcon = _UserCogIcon
export const UserPlusIcon = _UserPlusIcon
export const UserRoundIcon = _UserRoundIcon
export const UserSearchIcon = _UserSearchIcon
export const UserXIcon = _UserXIcon
export const UserIcon = _UserIcon

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bug-icon lucide-bug">
<path d="M12 20v-9" />
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
<path d="M14.12 3.88 16 2" />
<path d="M21 21a4 4 0 0 0-3.81-4" />
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
<path d="M22 13h-4" />
<path d="M3 21a4 4 0 0 1 3.81-4" />
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
<path d="M6 13H2" />
<path d="m8 2 1.88 1.88" />
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
</svg>

After

Width:  |  Height:  |  Size: 628 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down">
<path d="m6 9 6 6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 254 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-code-icon lucide-file-code">
<path
d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
<path d="M10 12.5 8 15l2 2.5" />
<path d="m14 12.5 2 2.5-2 2.5" />
</svg>

After

Width:  |  Height:  |  Size: 478 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-file-image-icon lucide-file-image">
<path
d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
<circle cx="10" cy="12" r="2" />
<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22" />
</svg>

After

Width:  |  Height:  |  Size: 508 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-up-icon lucide-folder-up"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="M12 10v6"/><path d="m9 13 3-3 3 3"/></svg>

After

Width:  |  Height:  |  Size: 416 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-icon lucide-folder">
<path
d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>

After

Width:  |  Height:  |  Size: 350 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-heart-minus-icon lucide-heart-minus"><path d="m14.876 18.99-1.368 1.323a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5a5.2 5.2 0 0 1-.244 1.572"/><path d="M15 15h6"/></svg>

After

Width:  |  Height:  |  Size: 438 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-filter-icon lucide-list-filter">
<path d="M2 5h20" />
<path d="M6 12h12" />
<path d="M9 19h6" />
</svg>

After

Width:  |  Height:  |  Size: 292 B

View File

@@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-shield-alert-icon lucide-shield-alert"
>
<path
d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"
/>
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>

After

Width:  |  Height:  |  Size: 487 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-shield-check-icon lucide-shield-check">
<path
d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
<path d="m9 12 2 2 4-4" />
</svg>

After

Width:  |  Height:  |  Size: 458 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-user-round-icon lucide-user-round">
<circle cx="12" cy="8" r="5" />
<path d="M20 21a8 8 0 0 0-16 0" />
</svg>

After

Width:  |  Height:  |  Size: 316 B

View File

@@ -0,0 +1,47 @@
<svg viewBox="0 50 250 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M63 134H154C154.515 134 155.017 133.944 155.5 133.839C155.983 133.944 156.485 134 157 134H209C212.866 134 216 130.866 216 127C216 123.134 212.866 120 209 120H203C199.134 120 196 116.866 196 113C196 109.134 199.134 106 203 106H222C225.866 106 229 102.866 229 99C229 95.134 225.866 92 222 92H200C203.866 92 207 88.866 207 85C207 81.134 203.866 78 200 78H136C139.866 78 143 74.866 143 71C143 67.134 139.866 64 136 64H79C75.134 64 72 67.134 72 71C72 74.866 75.134 78 79 78H39C35.134 78 32 81.134 32 85C32 88.866 35.134 92 39 92H64C67.866 92 71 95.134 71 99C71 102.866 67.866 106 64 106H24C20.134 106 17 109.134 17 113C17 116.866 20.134 120 24 120H63C59.134 120 56 123.134 56 127C56 130.866 59.134 134 63 134ZM226 134C229.866 134 233 130.866 233 127C233 123.134 229.866 120 226 120C222.134 120 219 123.134 219 127C219 130.866 222.134 134 226 134Z"
fill="var(--surface-2, #1D1F23)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M113.119 112.307C113.04 112.86 113 113.425 113 114C113 120.627 118.373 126 125 126C131.627 126 137 120.627 137 114C137 113.425 136.96 112.86 136.881 112.307H166V139C166 140.657 164.657 142 163 142H87C85.3431 142 84 140.657 84 139V112.307H113.119Z"
fill="var(--surface-1, #16181C)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M138 112C138 119.18 132.18 125 125 125C117.82 125 112 119.18 112 112C112 111.767 112.006 111.536 112.018 111.307H84L93.5604 83.0389C93.9726 81.8202 95.1159 81 96.4023 81H153.598C154.884 81 156.027 81.8202 156.44 83.0389L166 111.307H137.982C137.994 111.536 138 111.767 138 112Z"
fill="var(--surface-1, #16181C)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M136.098 112.955C136.098 118.502 131.129 124 125 124C118.871 124 113.902 118.502 113.902 112.955C113.902 112.775 113.908 111.596 113.918 111.419H93L101.161 91.5755C101.513 90.6338 102.489 90 103.587 90H146.413C147.511 90 148.487 90.6338 148.839 91.5755L157 111.419H136.082C136.092 111.596 136.098 112.775 136.098 112.955Z"
fill="var(--surface-2, #1D1F23)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M85.25 111.512V138C85.25 138.966 86.0335 139.75 87 139.75H163C163.966 139.75 164.75 138.966 164.75 138V111.512L155.255 83.4393C155.015 82.7285 154.348 82.25 153.598 82.25H96.4023C95.6519 82.25 94.985 82.7285 94.7446 83.4393L85.25 111.512Z"
stroke="var(--surface-4, #34363C)"
stroke-width="2.5"
/>
<path
d="M98 111C101.937 111 106.185 111 110.745 111C112.621 111 112.621 112.319 112.621 113C112.621 119.627 118.117 125 124.897 125C131.677 125 137.173 119.627 137.173 113C137.173 112.319 137.173 111 139.05 111H164M90.5737 111H93H90.5737Z"
stroke="var(--surface-4, #34363C)"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M150.1 58.3027L139 70.7559M124.1 54V70.7559V54ZM98 58.3027L109.1 70.7559L98 58.3027Z"
stroke="var(--surface-3, #27292E)"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -62,6 +62,7 @@ import _WindowsIcon from './external/windows.svg?component'
import _YouTubeIcon from './external/youtube.svg?component'
import _YouTubeGaming from './external/youtubegaming.svg?component'
import _YouTubeShortsIcon from './external/youtubeshorts.svg?component'
import _EmptyIllustration from './illustrations/empty.svg?component'
export const ModrinthIcon = _ModrinthIcon
export const BrowserWindowSuccessIllustration = _BrowserWindowSuccessIllustration
@@ -132,3 +133,5 @@ export const ElyByIcon = _ElyByIcon
export * from './generated-icons'
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'
export { default as SlimPlayerModel } from './models/slim-player.gltf?url'
export const EmptyIllustration = _EmptyIllustration

View File

@@ -235,3 +235,23 @@ h3 {
margin-block: var(--gap-md) var(--gap-md);
color: var(--color-contrast);
}
// Scrollbar styles
::-webkit-scrollbar {
width: 0.75rem;
height: 0.75rem;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-button-bg);
}
// Firefox scrollbar
* {
scrollbar-width: thin;
scrollbar-color: var(--color-button-bg) transparent;
}

View File

@@ -0,0 +1,87 @@
---
title: 'Streamlined Version Creation'
summary: 'Version creation is now dramatically more intelligent and easier for creators.'
date: 2025-12-18T12:50:00-08:00
authors: [AJfd8YH6, 6EjnV9Uf, Dc7EYhxG]
---
Hey everyone! As part of our ongoing work to improve the creator side of the platform, were shipping a new project version creation and editing today. This part of the product was showing its age, so weve overhauled it to set us up for the new project types we plan to ship in the new year!
## TL;DR
- Multi-file uploads with primary file detection and new supplementary file types
- Automatic detection of version number, subtitle, loaders, game versions, and environment bundled into a version summary
- A new loader selector that groups loaders by project type
- A new game version selector with search and selecting version ranges
- Project environments moved to be on a per-version basis
- A new dependency selector with search and smart suggestions
- Project gallery, versions, and publishing checklist moved into project settings
## File uploading
For starters, weve been centralizing all project editing into Project Settings to make the experience clearer and more approachable for new creators. Editing project versions now happens directly within Project Settings and projects look slightly different if youre the creator.
![Project page header showing the primary action as "Edit project" for the creator](./edit-button.webp)
You can create a new version by drag and dropping files into the versions table or creating a new version and uploading them. Multiple files can be uploaded at once.
The primary file is explicitly listed at the top and separate from any supplementary files. From there, you can add additional supplementary files and assign their types. Newly supported types include sources jar, dev jar, javadoc jar, and signature file.
<div class="video-wrapper mb-8">
<video autoplay loop muted playsinline>
<source src="https://cdn-raw.modrinth.com/blog/streamlined-version-creation/vid1.mp4" type="video/mp4" />
</video>
</div>
## Version summary
Once youve uploaded your files, youre taken to a summary page where we automatically detect the version number, subtitle, loaders, game versions, and environments based on the primary file and previous project versions.
Any field can be individually edited by clicking the edit button in the top right. For cases where were unable to detect something, that field simply wont appear in the summary and will instead show up as an additional step in the modal flow.
![Add details stage of the upload modal, where the user selects version type, number, subtitle, and can edit loaders, game versions, and environment metadata.](./details.webp)
## Loader selector
Weve added a refreshed loader selection screen that groups loaders by project type. You can click any loader tag to add it.
<div class="video-wrapper mb-8">
<video autoplay loop muted playsinline>
<source src="https://cdn-raw.modrinth.com/blog/streamlined-version-creation/vid2.mp4" type="video/mp4" />
</video>
</div>
## Game version selector
Game versions now have their own dedicated step. This was a major pain point for projects that support a wide range of game versions. You can search for versions or toggle between releases and snapshots. Select individual versions with a click, or use shift-click to select a range.
<div class="video-wrapper mb-8">
<video autoplay loop muted playsinline>
<source src="https://cdn-raw.modrinth.com/blog/streamlined-version-creation/vid3.mp4" type="video/mp4" />
</video>
</div>
## Environment selector
Project environments were released earlier this year, and we heard feedback that some projects need them configured at the version level. Weve moved environments out of project settings and into versions. For the vast majority of projects environments rarely change, so we automatically carry them over from a previous version that uses the same loader. You can always edit this if needed.
![Edit environment screen, showing a bunch of options to select such as client-side only, server-side only, and more.](./environments.webp)
## Dependency selector
Dependencies were another pain point, so weve added the ability to search projects and versions directly, no more copying IDs. We also suggest dependencies from the other versions youve uploaded with the same loader, making them easy to add with a single click.
<div class="video-wrapper mb-8">
<video autoplay loop muted playsinline>
<source src="https://cdn-raw.modrinth.com/blog/streamlined-version-creation/vid4.mp4" type="video/mp4" />
</video>
</div>
## Misc
- Gallery management has now also been moved into Project Settings
- The project publishing checklist now lives in Project Settings
<hr />
Thank you all for your continued support. We hope you have a great holiday and get some well-earned time with your families! 🎅

View File

@@ -28,6 +28,7 @@ import { article as russian_censorship } from "./russian_censorship";
import { article as skins_now_in_modrinth_app } from "./skins_now_in_modrinth_app";
import { article as standing_by_our_values } from "./standing_by_our_values";
import { article as standing_by_our_values_russian } from "./standing_by_our_values_russian";
import { article as streamlined_version_creation } from "./streamlined_version_creation";
import { article as two_years_of_modrinth } from "./two_years_of_modrinth";
import { article as two_years_of_modrinth_history } from "./two_years_of_modrinth_history";
import { article as whats_modrinth } from "./whats_modrinth";
@@ -38,6 +39,7 @@ export const articles = [
whats_modrinth,
two_years_of_modrinth,
two_years_of_modrinth_history,
streamlined_version_creation,
standing_by_our_values,
standing_by_our_values_russian,
skins_now_in_modrinth_app,

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
// AUTO-GENERATED FILE - DO NOT EDIT
export const article = {
html: () => import(`./streamlined_version_creation.content`).then(m => m.html),
title: "Streamlined Version Creation",
summary: "Version creation is now dramatically more intelligent and easier for creators.",
date: "2025-12-18T20:50:00.000Z",
slug: "streamlined-version-creation",
authors: ["AJfd8YH6","6EjnV9Uf","Dc7EYhxG"],
unlisted: false,
thumbnail: true,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@modrinth/assets": "workspace:*",
"@modrinth/utils": "workspace:*",
"@modrinth/api-client": "workspace:*",
"vue": "^3.5.13"
},
"devDependencies": {

View File

@@ -39,7 +39,7 @@ export const coreNags: Nag[] = [
status: 'required',
shouldShow: (context: NagContext) => context.versions.length < 1,
link: {
path: 'versions',
path: 'settings/versions',
title: defineMessage({
id: 'nags.versions.title',
defaultMessage: 'Visit versions page',
@@ -126,7 +126,7 @@ export const coreNags: Nag[] = [
)
},
link: {
path: 'gallery',
path: 'settings/gallery',
title: defineMessage({
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
@@ -151,7 +151,7 @@ export const coreNags: Nag[] = [
return context.project?.gallery?.length === 0 || !featuredGalleryImage
},
link: {
path: 'gallery',
path: 'settings/gallery',
title: defineMessage({
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
@@ -211,46 +211,6 @@ export const coreNags: Nag[] = [
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
{
id: 'select-environments',
title: defineMessage({
id: 'nags.select-environments.title',
defaultMessage: 'Select environments',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.select-environments.description',
defaultMessage: `Select the environments your {type, select, mod {mod} modpack {modpack} other {project}} functions on.`,
}),
{
type: context.project.project_type,
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
const excludedTypes = ['resourcepack', 'plugin', 'shader', 'datapack']
return (
context.project.versions.length > 0 &&
!excludedTypes.includes(context.project.project_type) &&
(context.project.client_side === 'unknown' ||
context.project.server_side === 'unknown' ||
(context.project.client_side === 'unsupported' &&
context.project.server_side === 'unsupported'))
)
},
link: {
path: 'settings/environment',
title: defineMessage({
id: 'nags.settings.environments.title',
defaultMessage: 'Visit environment settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-environment',
},
},
{
id: 'select-license',
title: defineMessage({

View File

@@ -1,4 +1,5 @@
import type { ReportQuickReply } from '../types/reports'
import type { QuickReply } from '../types/quick-reply'
import type { ExtendedReport } from '../types/reports'
export default [
{
@@ -67,4 +68,4 @@ export default [
message: async () => (await import('./messages/reports/stale.md?raw')).default,
private: false,
},
] as ReadonlyArray<ReportQuickReply>
] as ReadonlyArray<QuickReply<ExtendedReport>>

View File

@@ -0,0 +1,11 @@
import type { Labrinth } from '@modrinth/api-client'
import type { QuickReply } from '../types/quick-reply'
export interface TechReviewContext {
project: Labrinth.Projects.v3.Project
project_owner: Labrinth.TechReview.Internal.Ownership
reports: Labrinth.TechReview.Internal.FileReport[]
}
export default [] as ReadonlyArray<QuickReply<TechReviewContext>>

View File

@@ -4,10 +4,15 @@ export { finalPermissionMessages } from './data/modpack-permissions-stage'
export { default as nags } from './data/nags'
export * from './data/nags/index'
export { default as reportQuickReplies } from './data/report-quick-replies'
export {
type TechReviewContext,
default as techReviewQuickReplies,
} from './data/tech-review-quick-replies'
export * from './types/actions'
export * from './types/keybinds'
export * from './types/messages'
export * from './types/nags'
export * from './types/quick-reply'
export * from './types/reports'
export * from './types/stage'
export * from './utils'

View File

@@ -122,12 +122,6 @@
"nags.multiple-resolution-tags.title": {
"defaultMessage": "Select correct resolution"
},
"nags.select-environments.description": {
"defaultMessage": "Select the environments your {type, select, mod {mod} modpack {modpack} other {project}} functions on."
},
"nags.select-environments.title": {
"defaultMessage": "Select environments"
},
"nags.select-license.description": {
"defaultMessage": "Select the license your {type, select, mod {mod} modpack {modpack} resourcepack {resource pack} shader {shader} plugin {plugin} datapack {data pack} other {project}} is distributed under."
},
@@ -143,9 +137,6 @@
"nags.settings.description.title": {
"defaultMessage": "Visit description settings"
},
"nags.settings.environments.title": {
"defaultMessage": "Visit environment settings"
},
"nags.settings.license.title": {
"defaultMessage": "Visit license settings"
},

View File

@@ -0,0 +1,6 @@
export interface QuickReply<T = undefined> {
label: string
message: string | ((context: T) => Promise<string> | string)
shouldShow?: (context: T) => boolean
private?: boolean
}

View File

@@ -1,4 +1,4 @@
import type { DelphiReport, Project, Report, Thread, User, Version } from '@modrinth/utils'
import type { Project, Report, Thread, User, Version } from '@modrinth/utils'
export interface OwnershipTarget {
name: string
@@ -15,14 +15,3 @@ export interface ExtendedReport extends Report {
version?: Version
target?: OwnershipTarget
}
export interface ExtendedDelphiReport extends DelphiReport {
target?: OwnershipTarget
}
export interface ReportQuickReply {
label: string
message: string | ((report: ExtendedReport) => Promise<string> | string)
shouldShow?: (report: ExtendedReport) => boolean
private?: boolean
}

View File

@@ -304,7 +304,7 @@ export function flattenProjectVariables(project: Project): Record<string, string
vars[`TOS`] = `[Terms of Use](https://modrinth.com/legal/terms)`
vars[`COPYRIGHT_POLICY`] = `[Copyright Policy](https://modrinth.com/legal/copyright)`
vars[`SUPPORT`] =
`please visit the [Modrinth Help Center](https://support.modrinth.com/) and click the green bubble to contact support.`
`please visit the [Modrinth Help Center](https://support.modrinth.com/) and click the blue bubble to contact support.`
vars[`MODPACK_PERMISSIONS_GUIDE`] =
`our guide to [Obtaining Modpack Permissions](https://support.modrinth.com/en/articles/8797527-obtaining-modpack-permissions)`
vars[`MODPACKS_ON_MODRINTH`] =

View File

@@ -0,0 +1,15 @@
[package]
name = "modrinth-log"
edition.workspace = true
rust-version.workspace = true
repository.workspace = true
[dependencies]
dotenvy = { workspace = true }
eyre = { workspace = true }
tracing = { workspace = true }
tracing-ecs = { workspace = true }
tracing-subscriber = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1 @@
Service logging utilities.

View File

@@ -1,16 +1,14 @@
//! Service logging utilities.
#![doc = include_str!("../README.md")]
use std::str::FromStr;
use eyre::{Result, eyre};
use eyre::{Result, WrapErr, eyre};
use tracing::level_filters::LevelFilter;
use tracing_ecs::ECSLayerBuilder;
use tracing_subscriber::{
EnvFilter, layer::SubscriberExt, util::SubscriberInitExt,
};
use crate::{Context, env_var};
/// How this service will output logs to the terminal output.
///
/// See [`init`].
@@ -47,6 +45,21 @@ pub const OUTPUT_FORMAT_ENV_VAR: &str = "MODRINTH_OUTPUT_FORMAT";
///
/// Errors if logging could not be initialized.
pub fn init() -> Result<()> {
init_with_config(false)
}
/// Initializes logging for Modrinth services.
///
/// This uses [`OUTPUT_FORMAT_ENV_VAR`] to determine the [`OutputFormat`] to
/// use - see that type for details of each possible format.
///
/// - `compact`: if using [`OutputFormat::Human`], logs will not show timestamps
/// or target details.
///
/// # Errors
///
/// Errors if logging could not be initialized.
pub fn init_with_config(compact: bool) -> Result<()> {
let output_format = match env_var(OUTPUT_FORMAT_ENV_VAR) {
Ok(format) => format
.parse::<OutputFormat>()
@@ -58,12 +71,20 @@ pub fn init() -> Result<()> {
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
let result = match output_format {
OutputFormat::Human => tracing_subscriber::registry()
let result = match (output_format, compact) {
(OutputFormat::Human, false) => tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer())
.try_init(),
OutputFormat::Json => tracing_subscriber::registry()
(OutputFormat::Human, true) => tracing_subscriber::registry()
.with(env_filter)
.with(
tracing_subscriber::fmt::layer()
.without_time()
.with_target(false),
)
.try_init(),
(OutputFormat::Json, _) => tracing_subscriber::registry()
.with(env_filter)
.with(ECSLayerBuilder::default().stdout())
.try_init(),
@@ -72,3 +93,13 @@ pub fn init() -> Result<()> {
Ok(())
}
fn env_var(key: &str) -> Result<String> {
let value = dotenvy::var(key)
.wrap_err_with(|| eyre!("missing environment variable `{key}`"))?;
if value.is_empty() {
Err(eyre!("environment variable `{key}` is empty"))
} else {
Ok(value)
}
}

View File

@@ -9,11 +9,9 @@ actix-web = { workspace = true }
derive_more = { workspace = true, features = ["display", "error", "from"] }
dotenvy = { workspace = true }
eyre = { workspace = true }
modrinth-log = { workspace = true }
rust_decimal = { workspace = true, features = ["macros"], optional = true }
serde = { workspace = true, features = ["derive"] }
tracing = { workspace = true }
tracing-ecs = { workspace = true }
tracing-subscriber = { workspace = true }
utoipa = { workspace = true, optional = true }
[dev-dependencies]

View File

@@ -1,12 +1,12 @@
#![doc = include_str!("../README.md")]
mod error;
pub mod log;
#[cfg(feature = "decimal")]
pub mod decimal;
pub use error::*;
pub use modrinth_log as log;
use eyre::{Result, eyre};

View File

@@ -3,7 +3,6 @@ name = "muralpay"
version = "0.1.0"
edition.workspace = true
description = "Mural Pay API"
repository = "https://github.com/modrinth/code/"
license = "MIT"
keywords = []
categories = ["api-bindings"]
@@ -18,26 +17,19 @@ derive_more = { workspace = true, features = [
"error",
"from",
] }
reqwest = { workspace = true, features = ["default-tls", "http2", "json"] }
rust_decimal = { workspace = true, features = ["macros"] }
reqwest = { workspace = true, features = ["default-tls", "http2", "json"], optional = true }
rust_decimal = { workspace = true, features = ["macros", "serde-with-float"] }
rust_iso3166 = { workspace = true }
secrecy = { workspace = true }
secrecy = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_with = { workspace = true }
strum = { workspace = true, features = ["derive"] }
utoipa = { workspace = true, features = ["uuid"], optional = true }
utoipa = { workspace = true, features = ["chrono", "decimal", "uuid"], optional = true }
uuid = { workspace = true, features = ["serde"] }
[dev-dependencies]
clap = { workspace = true, features = ["derive"] }
color-eyre = { workspace = true }
dotenvy = { workspace = true }
eyre = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tracing-subscriber = { workspace = true }
[features]
client = ["dep:reqwest", "dep:secrecy"]
mock = ["dep:arc-swap"]
utoipa = ["dep:utoipa"]

View File

@@ -1,330 +0,0 @@
use std::{env, fmt::Debug, io};
use eyre::{Result, WrapErr, eyre};
use muralpay::{
AccountId, CounterpartyId, CreatePayout, CreatePayoutDetails, Dob,
FiatAccountType, FiatAndRailCode, FiatAndRailDetails, FiatFeeRequest,
FiatPayoutFee, MuralPay, PayoutMethodId, PayoutRecipientInfo,
PayoutRequestId, PhysicalAddress, TokenAmount, TokenFeeRequest,
TokenPayoutFee, UsdSymbol,
};
use rust_decimal::{Decimal, dec};
use serde::Serialize;
#[derive(Debug, clap::Parser)]
struct Args {
#[arg(short, long)]
output: Option<OutputFormat>,
#[clap(subcommand)]
command: Command,
}
#[derive(Debug, clap::Subcommand)]
enum Command {
/// Account listing and management
Account {
#[command(subcommand)]
command: AccountCommand,
},
/// Payouts and payout requests
Payout {
#[command(subcommand)]
command: PayoutCommand,
},
/// Counterparty management
Counterparty {
#[command(subcommand)]
command: CounterpartyCommand,
},
/// Payout method management
PayoutMethod {
#[command(subcommand)]
command: PayoutMethodCommand,
},
}
#[derive(Debug, clap::Subcommand)]
enum AccountCommand {
/// List all accounts
#[clap(alias = "ls")]
List,
}
#[derive(Debug, clap::Subcommand)]
enum PayoutCommand {
/// List all payout requests
#[clap(alias = "ls")]
List,
/// Get details for a single payout request
Get {
/// ID of the payout request
payout_request_id: PayoutRequestId,
},
/// Create a payout request
Create {
/// ID of the Mural account to send from
source_account_id: AccountId,
/// Description for this payout request
memo: Option<String>,
},
/// Get fees for a transaction
Fees {
#[command(subcommand)]
command: PayoutFeesCommand,
},
/// Get bank details for a fiat and rail code
BankDetails {
/// Fiat and rail code to fetch bank details for
fiat_and_rail_code: FiatAndRailCode,
},
}
#[derive(Debug, clap::Subcommand)]
enum PayoutFeesCommand {
/// Get fees for a token-to-fiat transaction
Token {
amount: Decimal,
fiat_and_rail_code: FiatAndRailCode,
},
/// Get fees for a fiat-to-token transaction
Fiat {
amount: Decimal,
fiat_and_rail_code: FiatAndRailCode,
},
}
#[derive(Debug, clap::Subcommand)]
enum CounterpartyCommand {
/// List all counterparties
#[clap(alias = "ls")]
List,
}
#[derive(Debug, clap::Subcommand)]
enum PayoutMethodCommand {
/// List payout methods for a counterparty
#[clap(alias = "ls")]
List {
/// ID of the counterparty
counterparty_id: CounterpartyId,
},
/// Delete a payout method
Delete {
/// ID of the counterparty
counterparty_id: CounterpartyId,
/// ID of the payout method to delete
payout_method_id: PayoutMethodId,
},
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum OutputFormat {
Json,
JsonMin,
}
#[tokio::main]
async fn main() -> Result<()> {
_ = dotenvy::dotenv();
color_eyre::install().expect("failed to install `color-eyre`");
tracing_subscriber::fmt().init();
let args = <Args as clap::Parser>::parse();
let of = args.output;
let api_url = env::var("MURALPAY_API_URL")
.unwrap_or_else(|_| muralpay::SANDBOX_API_URL.to_string());
let api_key = env::var("MURALPAY_API_KEY").wrap_err("no API key")?;
let transfer_api_key = env::var("MURALPAY_TRANSFER_API_KEY").ok();
let muralpay = MuralPay::new(api_url, api_key, transfer_api_key);
match args.command {
Command::Account {
command: AccountCommand::List,
} => run(of, muralpay.get_all_accounts().await?),
Command::Payout {
command: PayoutCommand::List,
} => run(of, muralpay.search_payout_requests(None, None).await?),
Command::Payout {
command: PayoutCommand::Get { payout_request_id },
} => run(of, muralpay.get_payout_request(payout_request_id).await?),
Command::Payout {
command:
PayoutCommand::Create {
source_account_id,
memo,
},
} => run(
of,
create_payout_request(
&muralpay,
source_account_id,
memo.as_deref(),
)
.await?,
),
Command::Payout {
command:
PayoutCommand::Fees {
command:
PayoutFeesCommand::Token {
amount,
fiat_and_rail_code,
},
},
} => run(
of,
get_fees_for_token_amount(&muralpay, amount, fiat_and_rail_code)
.await?,
),
Command::Payout {
command:
PayoutCommand::Fees {
command:
PayoutFeesCommand::Fiat {
amount,
fiat_and_rail_code,
},
},
} => run(
of,
get_fees_for_fiat_amount(&muralpay, amount, fiat_and_rail_code)
.await?,
),
Command::Payout {
command: PayoutCommand::BankDetails { fiat_and_rail_code },
} => run(of, muralpay.get_bank_details(&[fiat_and_rail_code]).await?),
Command::Counterparty {
command: CounterpartyCommand::List,
} => run(of, list_counterparties(&muralpay).await?),
Command::PayoutMethod {
command: PayoutMethodCommand::List { counterparty_id },
} => run(
of,
muralpay
.search_payout_methods(counterparty_id, None)
.await?,
),
Command::PayoutMethod {
command:
PayoutMethodCommand::Delete {
counterparty_id,
payout_method_id,
},
} => run(
of,
muralpay
.delete_payout_method(counterparty_id, payout_method_id)
.await?,
),
}
Ok(())
}
async fn create_payout_request(
muralpay: &MuralPay,
source_account_id: AccountId,
memo: Option<&str>,
) -> Result<()> {
muralpay
.create_payout_request(
source_account_id,
memo,
&[CreatePayout {
amount: TokenAmount {
token_amount: dec!(2.00),
token_symbol: muralpay::USDC.into(),
},
payout_details: CreatePayoutDetails::Fiat {
bank_name: "Foo Bank".into(),
bank_account_owner: "John Smith".into(),
developer_fee: None,
fiat_and_rail_details: FiatAndRailDetails::Usd {
symbol: UsdSymbol::Usd,
account_type: FiatAccountType::Checking,
bank_account_number: "123456789".into(),
// idk what the format is, https://wise.com/us/routing-number/bank/us-bank
bank_routing_number: "071004200".into(),
},
},
recipient_info: PayoutRecipientInfo::Individual {
first_name: "John".into(),
last_name: "Smith".into(),
email: "john.smith@example.com".into(),
date_of_birth: Dob::new(1970, 1, 1).unwrap(),
physical_address: PhysicalAddress {
address1: "1234 Elm Street".into(),
address2: Some("Apt 56B".into()),
country: rust_iso3166::US,
state: "CA".into(),
city: "Springfield".into(),
zip: "90001".into(),
},
},
supporting_details: None,
}],
)
.await?;
Ok(())
}
async fn get_fees_for_token_amount(
muralpay: &MuralPay,
amount: Decimal,
fiat_and_rail_code: FiatAndRailCode,
) -> Result<TokenPayoutFee> {
let fees = muralpay
.get_fees_for_token_amount(&[TokenFeeRequest {
amount: TokenAmount {
token_amount: amount,
token_symbol: muralpay::USDC.into(),
},
fiat_and_rail_code,
}])
.await?;
let fee = fees
.into_iter()
.next()
.ok_or_else(|| eyre!("no fee results returned"))?;
Ok(fee)
}
async fn get_fees_for_fiat_amount(
muralpay: &MuralPay,
amount: Decimal,
fiat_and_rail_code: FiatAndRailCode,
) -> Result<FiatPayoutFee> {
let fees = muralpay
.get_fees_for_fiat_amount(&[FiatFeeRequest {
fiat_amount: amount,
token_symbol: muralpay::USDC.into(),
fiat_and_rail_code,
}])
.await?;
let fee = fees
.into_iter()
.next()
.ok_or_else(|| eyre!("no fee results returned"))?;
Ok(fee)
}
async fn list_counterparties(muralpay: &MuralPay) -> Result<()> {
let _counterparties = muralpay.search_counterparties(None).await?;
Ok(())
}
fn run<T: Debug + Serialize>(output_format: Option<OutputFormat>, value: T) {
match output_format {
None => {
println!("{value:#?}");
}
Some(OutputFormat::Json) => {
_ = serde_json::to_writer_pretty(io::stdout(), &value)
}
Some(OutputFormat::JsonMin) => {
_ = serde_json::to_writer(io::stdout(), &value);
}
}
}

View File

@@ -1,83 +1,65 @@
use std::str::FromStr;
use chrono::{DateTime, Utc};
use derive_more::{Deref, Display};
use rust_decimal::Decimal;
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
Blockchain, FiatAmount, MuralError, MuralPay, TokenAmount, WalletDetails,
util::RequestExt,
use {
crate::{Blockchain, FiatAmount, TokenAmount, WalletDetails},
chrono::{DateTime, Utc},
derive_more::{Deref, Display},
rust_decimal::Decimal,
serde::{Deserialize, Serialize},
std::str::FromStr,
uuid::Uuid,
};
impl MuralPay {
pub async fn get_all_accounts(&self) -> Result<Vec<Account>, MuralError> {
mock!(self, get_all_accounts());
#[cfg(feature = "client")]
const _: () = {
use crate::{MuralError, RequestExt};
self.http_get(|base| format!("{base}/api/accounts"))
.send_mural()
.await
}
impl crate::Client {
pub async fn get_all_accounts(&self) -> Result<Vec<Account>, MuralError> {
maybe_mock!(self, get_all_accounts());
pub async fn get_account(
&self,
id: AccountId,
) -> Result<Account, MuralError> {
mock!(self, get_account(id));
self.http_get(|base| format!("{base}/api/accounts/{id}"))
.send_mural()
.await
}
pub async fn create_account(
&self,
name: impl AsRef<str>,
description: Option<impl AsRef<str>>,
) -> Result<Account, MuralError> {
mock!(
self,
create_account(
name.as_ref(),
description.as_ref().map(|x| x.as_ref()),
)
);
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
name: &'a str,
description: Option<&'a str>,
self.http_get(|base| format!("{base}/api/accounts"))
.send_mural()
.await
}
let body = Body {
name: name.as_ref(),
description: description.as_ref().map(|x| x.as_ref()),
};
pub async fn get_account(&self, id: AccountId) -> Result<Account, MuralError> {
maybe_mock!(self, get_account(id));
self.http
.post(format!("{}/api/accounts", self.api_url))
.bearer_auth(self.api_key.expose_secret())
.json(&body)
.send_mural()
.await
self.http_get(|base| format!("{base}/api/accounts/{id}"))
.send_mural()
.await
}
pub async fn create_account(
&self,
name: impl AsRef<str>,
description: Option<impl AsRef<str>>,
) -> Result<Account, MuralError> {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
name: &'a str,
description: Option<&'a str>,
}
maybe_mock!(
self,
create_account(name.as_ref(), description.as_ref().map(AsRef::as_ref))
);
let body = Body {
name: name.as_ref(),
description: description.as_ref().map(AsRef::as_ref),
};
self.http_post(|base| format!("{base}/api/accounts"))
.json(&body)
.send_mural()
.await
}
}
}
};
#[derive(
Debug,
Display,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Deref,
Serialize,
Deserialize,
)]
#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deref, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[display("{}", _0.hyphenated())]
pub struct AccountId(pub Uuid);
@@ -90,6 +72,12 @@ impl FromStr for AccountId {
}
}
impl From<AccountId> for Uuid {
fn from(value: AccountId) -> Self {
value.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]

View File

@@ -1,9 +1,10 @@
use std::{collections::HashMap, fmt};
use bytes::Bytes;
use derive_more::{Display, Error, From};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use {
bytes::Bytes,
derive_more::{Display, Error, From},
serde::{Deserialize, Serialize},
std::{collections::HashMap, fmt},
uuid::Uuid,
};
#[derive(Debug, Display, Error, From)]
pub enum MuralError {
@@ -27,43 +28,6 @@ pub enum MuralError {
pub type Result<T, E = MuralError> = std::result::Result<T, E>;
#[derive(Debug, Display, Error, From)]
pub enum TransferError {
#[display("no transfer API key")]
NoTransferKey,
#[display("API error")]
Api(Box<ApiError>),
#[display("request error")]
Request(reqwest::Error),
#[display("failed to decode response\n{json:?}")]
#[from(skip)]
Decode {
source: serde_json::Error,
json: Bytes,
},
#[display("failed to decode error response\n{json:?}")]
#[from(skip)]
DecodeError {
source: serde_json::Error,
json: Bytes,
},
}
impl From<MuralError> for TransferError {
fn from(value: MuralError) -> Self {
match value {
MuralError::Api(x) => Self::Api(Box::new(x)),
MuralError::Request(x) => Self::Request(x),
MuralError::Decode { source, json } => {
Self::Decode { source, json }
}
MuralError::DecodeError { source, json } => {
Self::DecodeError { source, json }
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
#[serde(rename_all = "camelCase")]
pub struct ApiError {
@@ -96,7 +60,7 @@ where
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut lines = vec![self.message.to_string()];
let mut lines = vec![self.message.clone()];
if !self.details.is_empty() {
lines.push("details:".into());
@@ -105,8 +69,7 @@ impl fmt::Display for ApiError {
if !self.params.is_empty() {
lines.push("params:".into());
lines
.extend(self.params.iter().map(|(k, v)| format!("- {k}: {v}")));
lines.extend(self.params.iter().map(|(k, v)| format!("- {k}: {v}")));
}
lines.push(format!("error name: {}", self.name));

View File

@@ -1,14 +1,15 @@
//! See [`MuralPayMock`].
use std::fmt::{self, Debug};
use crate::{
Account, AccountId, BankDetailsResponse, Counterparty, CounterpartyId,
CreateCounterparty, CreatePayout, FiatAndRailCode, FiatFeeRequest,
FiatPayoutFee, MuralError, Organization, OrganizationId, PayoutMethod,
PayoutMethodDetails, PayoutMethodId, PayoutRequest, PayoutRequestId,
PayoutStatusFilter, SearchParams, SearchRequest, SearchResponse,
TokenFeeRequest, TokenPayoutFee, TransferError, UpdateCounterparty,
use {
crate::{
Account, AccountId, BankDetailsResponse, Counterparty, CounterpartyId, CreateCounterparty,
CreatePayout, FiatAndRailCode, FiatFeeRequest, FiatPayoutFee, MuralError, Organization,
OrganizationId, PayoutMethod, PayoutMethodDetails, PayoutMethodId, PayoutRequest,
PayoutRequestId, PayoutStatusFilter, SearchParams, SearchRequest, SearchResponse,
TokenFeeRequest, TokenPayoutFee, UpdateCounterparty,
transaction::{Transaction, TransactionId},
},
std::fmt::{self, Debug},
};
macro_rules! impl_mock {
@@ -43,8 +44,8 @@ impl_mock! {
fn get_fees_for_token_amount(&[TokenFeeRequest]) -> Result<Vec<TokenPayoutFee>, MuralError>;
fn get_fees_for_fiat_amount(&[FiatFeeRequest]) -> Result<Vec<FiatPayoutFee>, MuralError>;
fn create_payout_request(AccountId, Option<&str>, &[CreatePayout]) -> Result<PayoutRequest, MuralError>;
fn execute_payout_request(PayoutRequestId) -> Result<PayoutRequest, TransferError>;
fn cancel_payout_request(PayoutRequestId) -> Result<PayoutRequest, TransferError>;
fn execute_payout_request(PayoutRequestId) -> Result<PayoutRequest, MuralError>;
fn cancel_payout_request(PayoutRequestId) -> Result<PayoutRequest, MuralError>;
fn get_bank_details(&[FiatAndRailCode]) -> Result<BankDetailsResponse, MuralError>;
fn search_payout_methods(CounterpartyId, Option<SearchParams<PayoutMethodId>>) -> Result<SearchResponse<PayoutMethodId, PayoutMethod>, MuralError>;
fn get_payout_method(CounterpartyId, PayoutMethodId) -> Result<PayoutMethod, MuralError>;
@@ -56,6 +57,8 @@ impl_mock! {
fn get_counterparty(CounterpartyId) -> Result<Counterparty, MuralError>;
fn create_counterparty(&CreateCounterparty) -> Result<Counterparty, MuralError>;
fn update_counterparty(CounterpartyId, &UpdateCounterparty) -> Result<Counterparty, MuralError>;
fn get_transaction(TransactionId) -> Result<Transaction, MuralError>;
fn search_transactions(AccountId, Option<SearchParams<AccountId>>) -> Result<SearchResponse<AccountId, Account>, MuralError>;
}
impl Debug for MuralPayMock {

View File

@@ -0,0 +1,122 @@
mod error;
pub use error::*;
use {
reqwest::{IntoUrl, RequestBuilder},
secrecy::{ExposeSecret, SecretString},
};
#[cfg(feature = "mock")]
mod mock;
#[cfg(feature = "mock")]
pub use mock::MuralPayMock;
use serde::de::DeserializeOwned;
#[derive(Debug, Clone)]
pub struct Client {
pub http: reqwest::Client,
pub api_url: String,
pub api_key: SecretString,
pub transfer_api_key: SecretString,
#[cfg(feature = "mock")]
pub mock: std::sync::Arc<arc_swap::ArcSwapOption<mock::MuralPayMock>>,
}
impl Client {
pub fn new(
api_url: impl Into<String>,
api_key: impl Into<SecretString>,
transfer_api_key: impl Into<SecretString>,
) -> Self {
Self {
http: reqwest::Client::new(),
api_url: api_url.into(),
api_key: api_key.into(),
transfer_api_key: transfer_api_key.into(),
#[cfg(feature = "mock")]
mock: std::sync::Arc::new(arc_swap::ArcSwapOption::empty()),
}
}
/// Creates a client which mocks responses.
#[cfg(feature = "mock")]
#[must_use]
pub fn from_mock(mock: mock::MuralPayMock) -> Self {
Self {
http: reqwest::Client::new(),
api_url: String::new(),
api_key: SecretString::from(String::new()),
transfer_api_key: SecretString::from(String::new()),
mock: std::sync::Arc::new(arc_swap::ArcSwapOption::from_pointee(mock)),
}
}
fn http_req(&self, make_req: impl FnOnce() -> RequestBuilder) -> RequestBuilder {
make_req()
.bearer_auth(self.api_key.expose_secret())
.header("accept", "application/json")
.header("content-type", "application/json")
}
pub(crate) fn http_get<U: IntoUrl>(&self, make_url: impl FnOnce(&str) -> U) -> RequestBuilder {
self.http_req(|| self.http.get(make_url(&self.api_url)))
}
pub(crate) fn http_post<U: IntoUrl>(&self, make_url: impl FnOnce(&str) -> U) -> RequestBuilder {
self.http_req(|| self.http.post(make_url(&self.api_url)))
}
pub(crate) fn http_put<U: IntoUrl>(&self, make_url: impl FnOnce(&str) -> U) -> RequestBuilder {
self.http_req(|| self.http.put(make_url(&self.api_url)))
}
pub(crate) fn http_delete<U: IntoUrl>(
&self,
make_url: impl FnOnce(&str) -> U,
) -> RequestBuilder {
self.http_req(|| self.http.delete(make_url(&self.api_url)))
}
pub async fn health(&self) -> reqwest::Result<()> {
self.http_get(|base| format!("{base}/api/health"))
.send()
.await?
.error_for_status()?;
Ok(())
}
}
pub trait RequestExt: Sized {
#[must_use]
fn transfer_auth(self, client: &Client) -> Self;
fn send_mural<T: DeserializeOwned>(
self,
) -> impl Future<Output = crate::Result<T>> + Send + Sync;
}
const HEADER_TRANSFER_API_KEY: &str = "transfer-api-key";
impl RequestExt for reqwest::RequestBuilder {
fn transfer_auth(self, client: &Client) -> Self {
self.header(
HEADER_TRANSFER_API_KEY,
client.transfer_api_key.expose_secret(),
)
}
async fn send_mural<T: DeserializeOwned>(self) -> crate::Result<T> {
let resp = self.send().await?;
let status = resp.status();
if status.is_client_error() || status.is_server_error() {
let json = resp.bytes().await?;
let err = serde_json::from_slice::<ApiError>(&json)
.map_err(|source| MuralError::DecodeError { source, json })?;
Err(MuralError::Api(err))
} else {
let json = resp.bytes().await?;
let t = serde_json::from_slice::<T>(&json)
.map_err(|source| MuralError::Decode { source, json })?;
Ok(t)
}
}
}

View File

@@ -1,96 +1,84 @@
use chrono::{DateTime, Utc};
use derive_more::{Deref, Display};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use uuid::Uuid;
use crate::{
MuralError, MuralPay, PhysicalAddress, SearchParams, SearchResponse,
util::RequestExt,
use {
crate::PhysicalAddress,
chrono::{DateTime, Utc},
derive_more::{Deref, Display},
serde::{Deserialize, Serialize},
std::str::FromStr,
uuid::Uuid,
};
impl MuralPay {
pub async fn search_counterparties(
&self,
params: Option<SearchParams<CounterpartyId>>,
) -> Result<SearchResponse<CounterpartyId, Counterparty>, MuralError> {
mock!(self, search_counterparties(params));
#[cfg(feature = "client")]
const _: () = {
use crate::{MuralError, RequestExt, SearchParams, SearchResponse};
self.http_post(|base| format!("{base}/api/counterparties/search"))
.query(&params.map(|p| p.to_query()).unwrap_or_default())
.send_mural()
.await
}
impl crate::Client {
pub async fn search_counterparties(
&self,
params: Option<SearchParams<CounterpartyId>>,
) -> Result<SearchResponse<CounterpartyId, Counterparty>, MuralError> {
maybe_mock!(self, search_counterparties(params));
pub async fn get_counterparty(
&self,
id: CounterpartyId,
) -> Result<Counterparty, MuralError> {
mock!(self, get_counterparty(id));
self.http_get(|base| {
format!("{base}/api/counterparties/counterparty/{id}")
})
.send_mural()
.await
}
pub async fn create_counterparty(
&self,
counterparty: &CreateCounterparty,
) -> Result<Counterparty, MuralError> {
mock!(self, create_counterparty(counterparty));
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
counterparty: &'a CreateCounterparty,
self.http_post(|base| format!("{base}/api/counterparties/search"))
.query(&params.map(|p| p.to_query()).unwrap_or_default())
.send_mural()
.await
}
let body = Body { counterparty };
pub async fn get_counterparty(
&self,
id: CounterpartyId,
) -> Result<Counterparty, MuralError> {
maybe_mock!(self, get_counterparty(id));
self.http_post(|base| format!("{base}/api/counterparties"))
.json(&body)
.send_mural()
.await
}
pub async fn update_counterparty(
&self,
id: CounterpartyId,
counterparty: &UpdateCounterparty,
) -> Result<Counterparty, MuralError> {
mock!(self, update_counterparty(id, counterparty));
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
counterparty: &'a UpdateCounterparty,
self.http_get(|base| format!("{base}/api/counterparties/counterparty/{id}"))
.send_mural()
.await
}
let body = Body { counterparty };
pub async fn create_counterparty(
&self,
counterparty: &CreateCounterparty,
) -> Result<Counterparty, MuralError> {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
counterparty: &'a CreateCounterparty,
}
self.http_put(|base| {
format!("{base}/api/counterparties/counterparty/{id}")
})
.json(&body)
.send_mural()
.await
maybe_mock!(self, create_counterparty(counterparty));
let body = Body { counterparty };
self.http_post(|base| format!("{base}/api/counterparties"))
.json(&body)
.send_mural()
.await
}
pub async fn update_counterparty(
&self,
id: CounterpartyId,
counterparty: &UpdateCounterparty,
) -> Result<Counterparty, MuralError> {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
counterparty: &'a UpdateCounterparty,
}
maybe_mock!(self, update_counterparty(id, counterparty));
let body = Body { counterparty };
self.http_put(|base| format!("{base}/api/counterparties/counterparty/{id}"))
.json(&body)
.send_mural()
.await
}
}
}
};
#[derive(
Debug,
Display,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Deref,
Serialize,
Deserialize,
)]
#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deref, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[display("{}", _0.hyphenated())]
pub struct CounterpartyId(pub Uuid);
@@ -103,6 +91,12 @@ impl FromStr for CounterpartyId {
}
}
impl From<CounterpartyId> for Uuid {
fn from(value: CounterpartyId) -> Self {
value.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]

View File

@@ -1,6 +1,7 @@
#![doc = include_str!("../README.md")]
macro_rules! mock {
#[cfg(feature = "client")]
macro_rules! maybe_mock {
($self:expr, $fn:ident ( $($args:expr),* $(,)? )) => {
#[cfg(feature = "mock")]
if let Some(mock) = &*($self).mock.load() {
@@ -11,26 +12,28 @@ macro_rules! mock {
mod account;
mod counterparty;
mod error;
mod organization;
mod payout;
mod payout_method;
mod serde_iso3166;
mod transaction;
mod util;
#[cfg(feature = "mock")]
pub mod mock;
pub use {
account::*, counterparty::*, error::*, organization::*, payout::*,
payout_method::*,
account::*, counterparty::*, organization::*, payout::*, payout_method::*,
transaction::*,
};
use {
rust_decimal::Decimal,
serde::{Deserialize, Serialize},
std::{ops::Deref, str::FromStr},
uuid::Uuid,
};
use rust_decimal::Decimal;
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use std::{ops::Deref, str::FromStr};
use uuid::Uuid;
#[cfg(feature = "client")]
mod client;
#[cfg(feature = "client")]
pub use client::*;
pub const API_URL: &str = "https://api.muralpay.com";
pub const SANDBOX_API_URL: &str = "https://api-staging.muralpay.com";
@@ -38,46 +41,6 @@ pub const SANDBOX_API_URL: &str = "https://api-staging.muralpay.com";
/// Default token symbol for [`TokenAmount::token_symbol`] values.
pub const USDC: &str = "USDC";
#[derive(Debug)]
pub struct MuralPay {
pub http: reqwest::Client,
pub api_url: String,
pub api_key: SecretString,
pub transfer_api_key: Option<SecretString>,
#[cfg(feature = "mock")]
mock: arc_swap::ArcSwapOption<mock::MuralPayMock>,
}
impl MuralPay {
pub fn new(
api_url: impl Into<String>,
api_key: impl Into<SecretString>,
transfer_api_key: Option<impl Into<SecretString>>,
) -> Self {
Self {
http: reqwest::Client::new(),
api_url: api_url.into(),
api_key: api_key.into(),
transfer_api_key: transfer_api_key.map(Into::into),
#[cfg(feature = "mock")]
mock: arc_swap::ArcSwapOption::empty(),
}
}
/// Creates a client which mocks responses.
#[cfg(feature = "mock")]
#[must_use]
pub fn from_mock(mock: mock::MuralPayMock) -> Self {
Self {
http: reqwest::Client::new(),
api_url: "".into(),
api_key: SecretString::from(String::new()),
transfer_api_key: None,
mock: arc_swap::ArcSwapOption::from_pointee(mock),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
@@ -119,7 +82,9 @@ pub enum FiatAccountType {
crate::util::display_as_serialize!(FiatAccountType);
#[derive(Debug, Clone, Copy, Serialize, Deserialize, strum::EnumIter)]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::EnumIter,
)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum FiatAndRailCode {
@@ -149,7 +114,7 @@ impl FromStr for FiatAndRailCode {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct WalletDetails {
@@ -157,7 +122,7 @@ pub struct WalletDetails {
pub wallet_address: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct TokenAmount {
@@ -166,7 +131,7 @@ pub struct TokenAmount {
pub token_symbol: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct FiatAmount {
@@ -195,7 +160,7 @@ impl<Id: Deref<Target = Uuid> + Clone> SearchParams<Id> {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SearchResponse<Id, T> {

View File

@@ -1,91 +1,81 @@
use std::str::FromStr;
use chrono::{DateTime, Utc};
use derive_more::{Deref, Display};
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
CurrencyCode, MuralError, MuralPay, SearchResponse, util::RequestExt,
use {
crate::CurrencyCode,
chrono::{DateTime, Utc},
derive_more::{Deref, Display},
serde::{Deserialize, Serialize},
std::str::FromStr,
uuid::Uuid,
};
impl MuralPay {
pub async fn search_organizations(
&self,
req: SearchRequest,
) -> Result<SearchResponse<OrganizationId, Organization>, MuralError> {
mock!(self, search_organizations(req.clone()));
#[cfg(feature = "client")]
const _: () = {
use crate::{MuralError, RequestExt, SearchResponse};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body {
#[serde(skip_serializing_if = "Option::is_none")]
filter: Option<Filter>,
impl crate::Client {
pub async fn search_organizations(
&self,
req: SearchRequest,
) -> Result<SearchResponse<OrganizationId, Organization>, MuralError> {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body {
#[serde(skip_serializing_if = "Option::is_none")]
filter: Option<Filter>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Filter {
#[serde(rename = "type")]
ty: FilterType,
name: String,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FilterType {
Name,
}
maybe_mock!(self, search_organizations(req.clone()));
let query = [
req.limit.map(|limit| ("limit", limit.to_string())),
req.next_id
.map(|next_id| ("nextId", next_id.hyphenated().to_string())),
]
.into_iter()
.flatten()
.collect::<Vec<_>>();
let body = Body {
filter: req.name.map(|name| Filter {
ty: FilterType::Name,
name,
}),
};
self.http_post(|base| format!("{base}/api/organizations/search"))
.query(&query)
.json(&body)
.send_mural()
.await
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Filter {
#[serde(rename = "type")]
ty: FilterType,
name: String,
pub async fn get_organization(
&self,
id: OrganizationId,
) -> Result<Organization, MuralError> {
maybe_mock!(self, get_organization(id));
self.http_post(|base| format!("{base}/api/organizations/{id}"))
.send_mural()
.await
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FilterType {
Name,
}
let query = [
req.limit.map(|limit| ("limit", limit.to_string())),
req.next_id
.map(|next_id| ("nextId", next_id.hyphenated().to_string())),
]
.into_iter()
.flatten()
.collect::<Vec<_>>();
let body = Body {
filter: req.name.map(|name| Filter {
ty: FilterType::Name,
name,
}),
};
self.http_post(|base| format!("{base}/api/organizations/search"))
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.json(&body)
.send_mural()
.await
}
};
pub async fn get_organization(
&self,
id: OrganizationId,
) -> Result<Organization, MuralError> {
mock!(self, get_organization(id));
self.http_post(|base| format!("{base}/api/organizations/{id}"))
.send_mural()
.await
}
}
#[derive(
Debug,
Display,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Deref,
Serialize,
Deserialize,
)]
#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deref, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[display("{}", _0.hyphenated())]
pub struct OrganizationId(pub Uuid);
@@ -98,6 +88,12 @@ impl FromStr for OrganizationId {
}
}
impl From<OrganizationId> for Uuid {
fn from(value: OrganizationId) -> Self {
value.0
}
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct SearchRequest {

View File

@@ -6,170 +6,188 @@
)
)]
use std::str::FromStr;
use chrono::{DateTime, Utc};
use derive_more::{Deref, Display, Error, From};
use rust_decimal::Decimal;
use rust_iso3166::CountryCode;
use serde::{Deserialize, Serialize};
use serde_with::{DeserializeFromStr, SerializeDisplay};
use uuid::Uuid;
use crate::{
AccountId, Blockchain, FiatAccountType, FiatAmount, FiatAndRailCode,
MuralError, MuralPay, SearchParams, SearchResponse, TokenAmount,
TransferError, WalletDetails, util::RequestExt,
use {
crate::{
AccountId, Blockchain, CounterpartyId, CurrencyCode, FiatAccountType,
FiatAmount, FiatAndRailCode, PayoutMethodId, TokenAmount,
TransactionId, WalletDetails,
},
chrono::{DateTime, Utc},
derive_more::{Deref, Display, Error, From},
rust_decimal::Decimal,
rust_iso3166::CountryCode,
serde::{Deserialize, Serialize},
serde_with::{DeserializeFromStr, SerializeDisplay},
std::str::FromStr,
uuid::Uuid,
};
impl MuralPay {
pub async fn search_payout_requests(
&self,
filter: Option<PayoutStatusFilter>,
params: Option<SearchParams<PayoutRequestId>>,
) -> Result<SearchResponse<PayoutRequestId, PayoutRequest>, MuralError>
{
mock!(self, search_payout_requests(filter, params));
#[cfg(feature = "client")]
const _: () = {
use crate::{MuralError, RequestExt, SearchParams, SearchResponse};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body {
// if we submit `null`, Mural errors; we have to explicitly exclude this field
#[serde(skip_serializing_if = "Option::is_none")]
impl crate::Client {
pub async fn search_payout_requests(
&self,
filter: Option<PayoutStatusFilter>,
params: Option<SearchParams<PayoutRequestId>>,
) -> Result<SearchResponse<PayoutRequestId, PayoutRequest>, MuralError>
{
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body {
// if we submit `null`, Mural errors; we have to explicitly exclude this field
#[serde(skip_serializing_if = "Option::is_none")]
filter: Option<PayoutStatusFilter>,
}
maybe_mock!(self, search_payout_requests(filter, params));
let body = Body { filter };
self.http_post(|base| format!("{base}/api/payouts/search"))
.query(&params.map(|p| p.to_query()).unwrap_or_default())
.json(&body)
.send_mural()
.await
}
let body = Body { filter };
pub async fn get_payout_request(
&self,
id: PayoutRequestId,
) -> Result<PayoutRequest, MuralError> {
maybe_mock!(self, get_payout_request(id));
self.http_post(|base| format!("{base}/api/payouts/search"))
.query(&params.map(|p| p.to_query()).unwrap_or_default())
self.http_get(|base| format!("{base}/api/payouts/payout/{id}"))
.send_mural()
.await
}
pub async fn get_fees_for_token_amount(
&self,
token_fee_requests: &[TokenFeeRequest],
) -> Result<Vec<TokenPayoutFee>, MuralError> {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
token_fee_requests: &'a [TokenFeeRequest],
}
maybe_mock!(self, get_fees_for_token_amount(token_fee_requests));
let body = Body { token_fee_requests };
self.http_post(|base| {
format!("{base}/api/payouts/fees/token-to-fiat")
})
.json(&body)
.send_mural()
.await
}
pub async fn get_payout_request(
&self,
id: PayoutRequestId,
) -> Result<PayoutRequest, MuralError> {
mock!(self, get_payout_request(id));
self.http_get(|base| format!("{base}/api/payouts/payout/{id}"))
.send_mural()
.await
}
pub async fn get_fees_for_token_amount(
&self,
token_fee_requests: &[TokenFeeRequest],
) -> Result<Vec<TokenPayoutFee>, MuralError> {
mock!(self, get_fees_for_token_amount(token_fee_requests));
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
token_fee_requests: &'a [TokenFeeRequest],
}
let body = Body { token_fee_requests };
pub async fn get_fees_for_fiat_amount(
&self,
fiat_fee_requests: &[FiatFeeRequest],
) -> Result<Vec<FiatPayoutFee>, MuralError> {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
fiat_fee_requests: &'a [FiatFeeRequest],
}
self.http_post(|base| format!("{base}/api/payouts/fees/token-to-fiat"))
maybe_mock!(self, get_fees_for_fiat_amount(fiat_fee_requests));
let body = Body { fiat_fee_requests };
self.http_post(|base| {
format!("{base}/api/payouts/fees/fiat-to-token")
})
.json(&body)
.send_mural()
.await
}
pub async fn get_fees_for_fiat_amount(
&self,
fiat_fee_requests: &[FiatFeeRequest],
) -> Result<Vec<FiatPayoutFee>, MuralError> {
mock!(self, get_fees_for_fiat_amount(fiat_fee_requests));
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
fiat_fee_requests: &'a [FiatFeeRequest],
}
let body = Body { fiat_fee_requests };
self.http_post(|base| format!("{base}/api/payouts/fees/fiat-to-token"))
.json(&body)
.send_mural()
.await
}
pub async fn create_payout_request(
&self,
source_account_id: AccountId,
memo: Option<impl AsRef<str>>,
payouts: &[CreatePayout],
) -> Result<PayoutRequest, MuralError> {
mock!(self, create_payout_request(source_account_id, memo.as_ref().map(|x| x.as_ref()), payouts));
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
pub async fn create_payout_request(
&self,
source_account_id: AccountId,
memo: Option<&'a str>,
payouts: &'a [CreatePayout],
memo: Option<impl AsRef<str>>,
payouts: &[CreatePayout],
) -> Result<PayoutRequest, MuralError> {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
source_account_id: AccountId,
memo: Option<&'a str>,
payouts: &'a [CreatePayout],
}
maybe_mock!(
self,
create_payout_request(
source_account_id,
memo.as_ref().map(AsRef::as_ref),
payouts
)
);
let body = Body {
source_account_id,
memo: memo.as_ref().map(AsRef::as_ref),
payouts,
};
self.http_post(|base| format!("{base}/api/payouts/payout"))
.json(&body)
.send_mural()
.await
}
let body = Body {
source_account_id,
memo: memo.as_ref().map(|x| x.as_ref()),
payouts,
};
pub async fn execute_payout_request(
&self,
id: PayoutRequestId,
) -> Result<PayoutRequest, MuralError> {
maybe_mock!(self, execute_payout_request(id));
self.http_post(|base| format!("{base}/api/payouts/payout"))
.json(&body)
self.http_post(|base| {
format!("{base}/api/payouts/payout/{id}/execute")
})
.transfer_auth(self)
.send_mural()
.await
}
}
pub async fn execute_payout_request(
&self,
id: PayoutRequestId,
) -> Result<PayoutRequest, TransferError> {
mock!(self, execute_payout_request(id));
pub async fn cancel_payout_request(
&self,
id: PayoutRequestId,
) -> Result<PayoutRequest, MuralError> {
maybe_mock!(self, cancel_payout_request(id));
self.http_post(|base| format!("{base}/api/payouts/payout/{id}/execute"))
.transfer_auth(self)?
self.http_post(|base| {
format!("{base}/api/payouts/payout/{id}/cancel")
})
.transfer_auth(self)
.send_mural()
.await
.map_err(From::from)
}
pub async fn get_bank_details(
&self,
fiat_currency_and_rail: &[FiatAndRailCode],
) -> Result<BankDetailsResponse, MuralError> {
maybe_mock!(self, get_bank_details(fiat_currency_and_rail));
let query = fiat_currency_and_rail
.iter()
.map(|code| ("fiatCurrencyAndRail", code.to_string()))
.collect::<Vec<_>>();
self.http_get(|base| format!("{base}/api/payouts/bank-details"))
.query(&query)
.send_mural()
.await
}
}
pub async fn cancel_payout_request(
&self,
id: PayoutRequestId,
) -> Result<PayoutRequest, TransferError> {
mock!(self, cancel_payout_request(id));
self.http_post(|base| format!("{base}/api/payouts/payout/{id}/cancel"))
.transfer_auth(self)?
.send_mural()
.await
.map_err(From::from)
}
pub async fn get_bank_details(
&self,
fiat_currency_and_rail: &[FiatAndRailCode],
) -> Result<BankDetailsResponse, MuralError> {
mock!(self, get_bank_details(fiat_currency_and_rail));
let query = fiat_currency_and_rail
.iter()
.map(|code| ("fiatCurrencyAndRail", code.to_string()))
.collect::<Vec<_>>();
self.http_get(|base| format!("{base}/api/payouts/bank-details"))
.query(&query)
.send_mural()
.await
}
}
};
#[derive(
Debug,
@@ -195,6 +213,12 @@ impl FromStr for PayoutRequestId {
}
}
impl From<PayoutRequestId> for Uuid {
fn from(value: PayoutRequestId) -> Self {
value.0
}
}
#[derive(
Debug,
Display,
@@ -219,6 +243,12 @@ impl FromStr for PayoutId {
}
}
impl From<PayoutId> for Uuid {
fn from(value: PayoutId) -> Self {
value.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "camelCase")]
@@ -226,7 +256,7 @@ pub enum PayoutStatusFilter {
PayoutStatus { statuses: Vec<PayoutStatus> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct PayoutRequest {
@@ -251,7 +281,7 @@ pub enum PayoutStatus {
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct Payout {
@@ -260,9 +290,10 @@ pub struct Payout {
pub updated_at: DateTime<Utc>,
pub amount: TokenAmount,
pub details: PayoutDetails,
pub recipient_info: PayoutRecipientInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PayoutDetails {
@@ -270,7 +301,7 @@ pub enum PayoutDetails {
Blockchain(BlockchainPayoutDetails),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct FiatPayoutDetails {
@@ -286,7 +317,7 @@ pub struct FiatPayoutDetails {
pub developer_fee: Option<DeveloperFee>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum FiatPayoutStatus {
@@ -310,7 +341,69 @@ pub enum FiatPayoutStatus {
reason: String,
error_code: FiatPayoutErrorCode,
},
#[serde(rename_all = "camelCase")]
Canceled,
// since 1.31
#[serde(rename_all = "camelCase")]
RefundInProgress {
error_code: RefundErrorCode,
failure_reason: String,
refund_initiated_at: DateTime<Utc>,
},
// since 1.31
#[serde(rename_all = "camelCase")]
Refunded {
error_code: RefundErrorCode,
failure_reason: String,
refund_completed_at: DateTime<Utc>,
refund_initiated_at: DateTime<Utc>,
refund_transaction_id: TransactionId,
},
}
// since 1.31
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")]
pub enum RefundErrorCode {
Unknown,
AccountNumberIncorrect,
RejectedByBank,
AccountTypeIncorrect,
AccountClosed,
BeneficiaryDocumentationIncorrect,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum FiatPayoutStatusKind {
Created,
Pending,
OnHold,
Completed,
Failed,
Canceled,
RefundInProgress,
Refunded,
}
impl FiatPayoutStatus {
#[must_use]
pub const fn kind(&self) -> FiatPayoutStatusKind {
match self {
Self::Created { .. } => FiatPayoutStatusKind::Created,
Self::Pending { .. } => FiatPayoutStatusKind::Pending,
Self::OnHold { .. } => FiatPayoutStatusKind::OnHold,
Self::Completed { .. } => FiatPayoutStatusKind::Completed,
Self::Failed { .. } => FiatPayoutStatusKind::Failed,
Self::Canceled { .. } => FiatPayoutStatusKind::Canceled,
Self::RefundInProgress { .. } => {
FiatPayoutStatusKind::RefundInProgress
}
Self::Refunded { .. } => FiatPayoutStatusKind::Refunded,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -325,7 +418,7 @@ pub enum FiatPayoutErrorCode {
BeneficiaryDocumentationIncorrect,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeveloperFee {
@@ -333,7 +426,7 @@ pub struct DeveloperFee {
pub developer_fee_percentage: Option<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct BlockchainPayoutDetails {
@@ -353,13 +446,51 @@ pub enum BlockchainPayoutStatus {
Canceled,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PayoutRecipientInfo {
#[serde(rename_all = "camelCase")]
Counterparty {
counterparty_id: CounterpartyId,
payout_method_id: PayoutMethodId,
},
#[serde(rename_all = "camelCase")]
Inline {
name: String,
details: InlineRecipientDetails,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum InlineRecipientDetails {
#[serde(rename_all = "camelCase")]
Fiat { details: InlineFiatRecipientDetails },
#[serde(rename_all = "camelCase")]
Blockchain {
wallet_address: String,
blockchain: Blockchain,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct InlineFiatRecipientDetails {
pub fiat_currency_code: CurrencyCode,
pub bank_name: String,
pub truncated_bank_account_number: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CreatePayout {
pub amount: TokenAmount,
pub payout_details: CreatePayoutDetails,
pub recipient_info: PayoutRecipientInfo,
pub recipient_info: CreatePayoutRecipientInfo,
pub supporting_details: Option<SupportingDetails>,
}
@@ -487,7 +618,8 @@ pub enum FiatAndRailDetails {
}
impl FiatAndRailDetails {
pub fn code(&self) -> FiatAndRailCode {
#[must_use]
pub const fn code(&self) -> FiatAndRailCode {
match self {
Self::Usd { .. } => FiatAndRailCode::Usd,
Self::Cop { .. } => FiatAndRailCode::Cop,
@@ -607,7 +739,7 @@ pub enum PixAccountType {
#[derive(Debug, Clone, Serialize, Deserialize, From)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PayoutRecipientInfo {
pub enum CreatePayoutRecipientInfo {
#[serde(rename_all = "camelCase")]
Individual {
first_name: String,
@@ -624,20 +756,23 @@ pub enum PayoutRecipientInfo {
},
}
impl PayoutRecipientInfo {
impl CreatePayoutRecipientInfo {
#[must_use]
pub fn email(&self) -> &str {
match self {
PayoutRecipientInfo::Individual { email, .. } => email,
PayoutRecipientInfo::Business { email, .. } => email,
Self::Individual { email, .. } | Self::Business { email, .. } => {
email
}
}
}
pub fn physical_address(&self) -> &PhysicalAddress {
#[must_use]
pub const fn physical_address(&self) -> &PhysicalAddress {
match self {
PayoutRecipientInfo::Individual {
Self::Individual {
physical_address, ..
} => physical_address,
PayoutRecipientInfo::Business {
}
| Self::Business {
physical_address, ..
} => physical_address,
}

View File

@@ -1,90 +1,104 @@
use std::str::FromStr;
use chrono::{DateTime, Utc};
use derive_more::{Deref, Display, Error};
use serde::{Deserialize, Serialize};
use serde_with::DeserializeFromStr;
use uuid::Uuid;
use crate::{
ArsSymbol, BobSymbol, BrlSymbol, ClpSymbol, CopSymbol, CounterpartyId,
CrcSymbol, DocumentType, EurSymbol, FiatAccountType, MuralError, MuralPay,
MxnSymbol, PenSymbol, SearchParams, SearchResponse, UsdSymbol,
WalletDetails, ZarSymbol, util::RequestExt,
use {
crate::{
ArsSymbol, BobSymbol, BrlSymbol, ClpSymbol, CopSymbol, CounterpartyId, CrcSymbol,
DocumentType, EurSymbol, FiatAccountType, MxnSymbol, PenSymbol, UsdSymbol, WalletDetails,
ZarSymbol,
},
chrono::{DateTime, Utc},
derive_more::{Deref, Display, Error},
serde::{Deserialize, Serialize},
serde_with::DeserializeFromStr,
std::str::FromStr,
uuid::Uuid,
};
impl MuralPay {
pub async fn search_payout_methods(
&self,
counterparty_id: CounterpartyId,
params: Option<SearchParams<PayoutMethodId>>,
) -> Result<SearchResponse<PayoutMethodId, PayoutMethod>, MuralError> {
mock!(self, search_payout_methods(counterparty_id, params));
#[cfg(feature = "client")]
const _: () = {
use crate::{MuralError, RequestExt, SearchParams, SearchResponse};
self.http_post(|base| {
format!(
"{base}/api/counterparties/{counterparty_id}/payout-methods/search"
)
})
.query(&params.map(|p| p.to_query()).unwrap_or_default())
.send_mural()
.await
}
impl crate::Client {
pub async fn search_payout_methods(
&self,
counterparty_id: CounterpartyId,
params: Option<SearchParams<PayoutMethodId>>,
) -> Result<SearchResponse<PayoutMethodId, PayoutMethod>, MuralError> {
maybe_mock!(self, search_payout_methods(counterparty_id, params));
pub async fn get_payout_method(
&self,
counterparty_id: CounterpartyId,
payout_method_id: PayoutMethodId,
) -> Result<PayoutMethod, MuralError> {
mock!(self, get_payout_method(counterparty_id, payout_method_id));
self.http_get(|base| format!("{base}/api/counterparties/{counterparty_id}/payout-methods/{payout_method_id}"))
self.http_post(|base| {
format!("{base}/api/counterparties/{counterparty_id}/payout-methods/search")
})
.query(&params.map(|p| p.to_query()).unwrap_or_default())
.send_mural()
.await
}
pub async fn create_payout_method(
&self,
counterparty_id: CounterpartyId,
alias: impl AsRef<str>,
payout_method: &PayoutMethodDetails,
) -> Result<PayoutMethod, MuralError> {
mock!(self, create_payout_method(counterparty_id, alias.as_ref(), payout_method));
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
alias: &'a str,
payout_method: &'a PayoutMethodDetails,
}
let body = Body {
alias: alias.as_ref(),
payout_method,
};
pub async fn get_payout_method(
&self,
counterparty_id: CounterpartyId,
payout_method_id: PayoutMethodId,
) -> Result<PayoutMethod, MuralError> {
maybe_mock!(self, get_payout_method(counterparty_id, payout_method_id));
self.http_post(|base| {
format!(
"{base}/api/counterparties/{counterparty_id}/payout-methods"
)
})
.json(&body)
.send_mural()
.await
}
pub async fn delete_payout_method(
&self,
counterparty_id: CounterpartyId,
payout_method_id: PayoutMethodId,
) -> Result<(), MuralError> {
mock!(self, delete_payout_method(counterparty_id, payout_method_id));
self.http_delete(|base| format!("{base}/api/counterparties/{counterparty_id}/payout-methods/{payout_method_id}"))
self.http_get(|base| {
format!(
"{base}/api/counterparties/{counterparty_id}/payout-methods/{payout_method_id}"
)
})
.send_mural()
.await
}
pub async fn create_payout_method(
&self,
counterparty_id: CounterpartyId,
alias: impl AsRef<str>,
payout_method: &PayoutMethodDetails,
) -> Result<PayoutMethod, MuralError> {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
alias: &'a str,
payout_method: &'a PayoutMethodDetails,
}
maybe_mock!(
self,
create_payout_method(counterparty_id, alias.as_ref(), payout_method)
);
let body = Body {
alias: alias.as_ref(),
payout_method,
};
self.http_post(|base| {
format!("{base}/api/counterparties/{counterparty_id}/payout-methods")
})
.json(&body)
.send_mural()
.await
}
pub async fn delete_payout_method(
&self,
counterparty_id: CounterpartyId,
payout_method_id: PayoutMethodId,
) -> Result<(), MuralError> {
maybe_mock!(
self,
delete_payout_method(counterparty_id, payout_method_id)
);
self.http_delete(|base| {
format!(
"{base}/api/counterparties/{counterparty_id}/payout-methods/{payout_method_id}"
)
})
.send_mural()
.await
}
}
}
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
@@ -107,18 +121,7 @@ pub enum PayoutMethodPixAccountType {
BankAccount,
}
#[derive(
Debug,
Display,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Deref,
Serialize,
Deserialize,
)]
#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deref, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[display("{}", _0.hyphenated())]
pub struct PayoutMethodId(pub Uuid);

View File

@@ -1,12 +1,10 @@
use serde::{Deserialize, de::Error};
use std::borrow::Cow;
use {
rust_iso3166::CountryCode,
serde::{Deserialize, de::Error},
std::borrow::Cow,
};
use rust_iso3166::CountryCode;
pub fn serialize<S: serde::Serializer>(
v: &CountryCode,
serializer: S,
) -> Result<S::Ok, S::Error> {
pub fn serialize<S: serde::Serializer>(v: &CountryCode, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(v.alpha2)
}
@@ -17,8 +15,6 @@ pub fn deserialize<'de, D: serde::Deserializer<'de>>(
rust_iso3166::ALPHA2_MAP
.get(&country_code)
.copied()
.ok_or_else(|| {
D::Error::custom("invalid ISO 3166 alpha-2 country code")
})
.ok_or_else(|| D::Error::custom("invalid ISO 3166 alpha-2 country code"))
})
}

View File

@@ -0,0 +1,138 @@
use std::str::FromStr;
use chrono::{DateTime, Utc};
use derive_more::{Deref, Display};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{AccountId, Blockchain, FiatAmount, PayoutId, PayoutRequestId, TokenAmount};
#[cfg(feature = "client")]
const _: () = {
use crate::{Account, MuralError, RequestExt, SearchParams, SearchResponse};
impl crate::Client {
pub async fn get_transaction(&self, id: TransactionId) -> Result<Transaction, MuralError> {
maybe_mock!(self, get_transaction(id));
self.http_get(|base| format!("{base}/api/transactions/{id}"))
.send_mural()
.await
}
pub async fn search_transactions(
&self,
account_id: AccountId,
params: Option<SearchParams<AccountId>>,
) -> Result<SearchResponse<AccountId, Account>, MuralError> {
maybe_mock!(self, search_transactions(account_id, params));
self.http_post(|base| format!("{base}/api/transactions/search/account/{account_id}"))
.query(&params.map(|p| p.to_query()).unwrap_or_default())
.send_mural()
.await
}
}
};
#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deref, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[display("{}", _0.hyphenated())]
pub struct TransactionId(pub Uuid);
impl FromStr for TransactionId {
type Err = <Uuid as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<Uuid>().map(Self)
}
}
impl From<TransactionId> for Uuid {
fn from(value: TransactionId) -> Self {
value.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct Transaction {
pub id: TransactionId,
pub hash: String,
pub transaction_execution_date: DateTime<Utc>,
pub memo: Option<String>,
pub blockchain: Blockchain,
pub amount: TokenAmount,
pub account_id: AccountId,
// pub counterparty_info,
pub transaction_details: TransactionDetails,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum TransactionDetails {
#[serde(rename_all = "camelCase")]
Payout {
payout_request_id: PayoutRequestId,
payout_id: PayoutId,
},
#[serde(rename_all = "camelCase")]
Deposit { details: DepositDetails },
#[serde(rename_all = "camelCase")]
ExternalPayout { recipient_wallet_address: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum DepositDetails {
#[serde(rename_all = "camelCase")]
Fiat {
deposit_id: Uuid,
created_at: DateTime<Utc>,
sent_fiat_amount: FiatAmount,
sender_metadata: Option<SenderMetadata>,
deposit_status_info: DepositStatus,
},
#[serde(rename_all = "camelCase")]
Blockchain {
sender_address: String,
blockchain: Blockchain,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum SenderMetadata {
#[serde(rename_all = "camelCase")]
Ach {
ach_routing_number: String,
sender_name: String,
description: Option<String>,
trace_number: String,
},
#[serde(rename_all = "camelCase")]
Wire {
wire_routing_number: String,
sender_name: Option<String>,
bank_name: String,
bank_beneficiary_name: String,
imad: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum DepositStatus {
#[serde(rename_all = "camelCase")]
AwaitingFunds,
#[serde(rename_all = "camelCase")]
Completed {
initiated_at: DateTime<Utc>,
completed_at: DateTime<Utc>,
},
}

View File

@@ -1,85 +1,3 @@
use reqwest::{IntoUrl, RequestBuilder};
use secrecy::ExposeSecret;
use serde::de::DeserializeOwned;
use crate::{ApiError, MuralError, MuralPay, TransferError};
impl MuralPay {
fn http_req(
&self,
make_req: impl FnOnce() -> RequestBuilder,
) -> RequestBuilder {
make_req()
.bearer_auth(self.api_key.expose_secret())
.header("accept", "application/json")
.header("content-type", "application/json")
}
pub(crate) fn http_get<U: IntoUrl>(
&self,
make_url: impl FnOnce(&str) -> U,
) -> RequestBuilder {
self.http_req(|| self.http.get(make_url(&self.api_url)))
}
pub(crate) fn http_post<U: IntoUrl>(
&self,
make_url: impl FnOnce(&str) -> U,
) -> RequestBuilder {
self.http_req(|| self.http.post(make_url(&self.api_url)))
}
pub(crate) fn http_put<U: IntoUrl>(
&self,
make_url: impl FnOnce(&str) -> U,
) -> RequestBuilder {
self.http_req(|| self.http.put(make_url(&self.api_url)))
}
pub(crate) fn http_delete<U: IntoUrl>(
&self,
make_url: impl FnOnce(&str) -> U,
) -> RequestBuilder {
self.http_req(|| self.http.delete(make_url(&self.api_url)))
}
}
pub trait RequestExt: Sized {
fn transfer_auth(self, client: &MuralPay) -> Result<Self, TransferError>;
async fn send_mural<T: DeserializeOwned>(self) -> crate::Result<T>;
}
const HEADER_TRANSFER_API_KEY: &str = "transfer-api-key";
impl RequestExt for reqwest::RequestBuilder {
fn transfer_auth(self, client: &MuralPay) -> Result<Self, TransferError> {
let transfer_api_key = client
.transfer_api_key
.as_ref()
.ok_or(TransferError::NoTransferKey)?;
Ok(self
.header(HEADER_TRANSFER_API_KEY, transfer_api_key.expose_secret()))
}
async fn send_mural<T: DeserializeOwned>(self) -> crate::Result<T> {
let resp = self.send().await?;
let status = resp.status();
if status.is_client_error() || status.is_server_error() {
let json = resp.bytes().await?;
let err = serde_json::from_slice::<ApiError>(&json)
.map_err(|source| MuralError::DecodeError { source, json })?;
Err(MuralError::Api(err))
} else {
let json = resp.bytes().await?;
let t = serde_json::from_slice::<T>(&json)
.map_err(|source| MuralError::Decode { source, json })?;
Ok(t)
}
}
}
macro_rules! display_as_serialize {
($T:ty) => {
const _: () = {
@@ -87,8 +5,7 @@ macro_rules! display_as_serialize {
impl fmt::Display for $T {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let value =
serde_json::to_value(self).map_err(|_| fmt::Error)?;
let value = serde_json::to_value(self).map_err(|_| fmt::Error)?;
let value = value.as_str().ok_or(fmt::Error)?;
write!(f, "{value}")
}
@@ -96,5 +13,4 @@ macro_rules! display_as_serialize {
};
};
}
pub(crate) use display_as_serialize;

View File

@@ -29,6 +29,7 @@
"stripe": "^18.1.1",
"typescript": "^5.4.5",
"vue": "^3.5.13",
"vue-component-type-helpers": "^3.1.8",
"vue-router": "4.3.0"
},
"dependencies": {

View File

@@ -0,0 +1,2 @@
export { default as AffiliateLinkCard } from './AffiliateLinkCard.vue'
export { default as AffiliateLinkCreateModal } from './AffiliateLinkCreateModal.vue'

View File

@@ -29,7 +29,7 @@
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import { computed, ref, useSlots } from 'vue'
import { computed, ref, useSlots, watch } from 'vue'
const props = withDefaults(
defineProps<{
@@ -56,6 +56,16 @@ const emit = defineEmits(['onOpen', 'onClose'])
const slots = useSlots()
watch(
() => props.openByDefault,
(newValue) => {
if (newValue !== toggledOpen.value) {
toggledOpen.value = newValue
}
},
{ immediate: true },
)
function open() {
toggledOpen.value = true
emit('onOpen')

View File

@@ -8,7 +8,10 @@
<div :class="['flex gap-2 items-start', (header || $slots.header) && 'flex-col']">
<div class="flex gap-2 items-start" :class="header || $slots.header ? 'w-full' : 'contents'">
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
<component :is="icons[type]" :class="['h-6 w-6 flex-none', iconClasses[type]]" />
<component
:is="getSeverityIcon(type)"
:class="['h-6 w-6 flex-none', iconClasses[type]]"
/>
</slot>
<div v-if="header || $slots.header" class="font-semibold text-base">
<slot name="header">{{ header }}</slot>
@@ -25,7 +28,7 @@
</template>
<script setup lang="ts">
import { InfoIcon, IssuesIcon, XCircleIcon } from '@modrinth/assets'
import { getSeverityIcon } from '../../utils'
withDefaults(
defineProps<{
@@ -53,10 +56,4 @@ const iconClasses = {
warning: 'text-brand-orange',
critical: 'text-brand-red',
}
const icons = {
info: InfoIcon,
warning: IssuesIcon,
critical: XCircleIcon,
}
</script>

View File

@@ -69,6 +69,14 @@
<XIcon aria-hidden="true" /> {{ formatMessage(messages.closedLabel) }}
</template>
<!-- Technical review verdicts -->
<template v-else-if="type === 'safe'">
<ShieldCheckIcon aria-hidden="true" /> {{ formatMessage(messages.safeLabel) }}
</template>
<template v-else-if="type === 'unsafe'">
<BugIcon aria-hidden="true" /> {{ formatMessage(messages.unsafeLabel) }}
</template>
<!-- Other -->
<template v-else> <span class="circle" /> {{ capitalizeString(type) }} </template>
</span>
@@ -78,6 +86,7 @@
import {
ArchiveIcon,
BoxIcon,
BugIcon,
CalendarIcon,
CheckIcon,
EyeOffIcon,
@@ -86,6 +95,7 @@ import {
LockIcon,
ModrinthIcon,
ScaleIcon,
ShieldCheckIcon,
UpdatedIcon,
XIcon,
} from '@modrinth/assets'
@@ -153,6 +163,10 @@ const messages = defineMessages({
id: 'omorphia.component.badge.label.returned',
defaultMessage: 'Returned',
},
safeLabel: {
id: 'omorphia.component.badge.label.safe',
defaultMessage: 'Pass',
},
scheduledLabel: {
id: 'omorphia.component.badge.label.scheduled',
defaultMessage: 'Scheduled',
@@ -165,6 +179,10 @@ const messages = defineMessages({
id: 'omorphia.component.badge.label.unlisted',
defaultMessage: 'Unlisted',
},
unsafeLabel: {
id: 'omorphia.component.badge.label.unsafe',
defaultMessage: 'Fail',
},
withheldLabel: {
id: 'omorphia.component.badge.label.withheld',
defaultMessage: 'Withheld',
@@ -204,6 +222,7 @@ defineProps<{
&.type--rejected,
&.type--returned,
&.type--failed,
&.type--unsafe,
&.red {
--badge-color: var(--color-red);
}
@@ -220,6 +239,7 @@ defineProps<{
&.type--admin,
&.type--processed,
&.type--approved-general,
&.type--safe,
&.green {
--badge-color: var(--color-green);
}

View File

@@ -43,6 +43,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
})
const accentedButton = computed(() =>
@@ -69,6 +73,7 @@ const classes = computed(() => {
'btn-hover-filled-only': props.hoverFilledOnly,
'btn-outline': props.outline,
'color-accent-contrast': accentedButton,
disabled: props.disabled,
}
})
</script>
@@ -78,10 +83,14 @@ const classes = computed(() => {
v-if="link && link.startsWith('/')"
class="btn"
:class="classes"
:to="link"
:to="disabled ? '' : link"
:target="external ? '_blank' : '_self'"
@click="
(event) => {
if (disabled) {
event.preventDefault()
return
}
if (action) {
action(event)
}
@@ -96,10 +105,14 @@ const classes = computed(() => {
v-else-if="link"
class="btn"
:class="classes"
:href="link"
:href="disabled ? undefined : link"
:target="external ? '_blank' : '_self'"
@click="
(event) => {
if (disabled) {
event.preventDefault()
return
}
if (action) {
action(event)
}
@@ -110,7 +123,7 @@ const classes = computed(() => {
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
<UnknownIcon v-if="!$slots.default" />
</a>
<button v-else class="btn" :class="classes" @click="action">
<button v-else class="btn" :class="classes" :disabled="disabled" @click="action">
<slot />
<UnknownIcon v-if="!$slots.default" />
</button>

View File

@@ -3,8 +3,12 @@
<Button
v-for="item in items"
:key="formatLabel(item)"
class="btn"
:class="{ selected: selected === item, capitalize: capitalize }"
class="btn !brightness-100 hover:!brightness-125"
:class="{
selected: selected === item,
capitalize: capitalize,
'!px-2.5 !py-1.5': size === 'small',
}"
@click="toggleItem(item)"
>
<CheckIcon v-if="selected === item" />
@@ -24,14 +28,17 @@ const props = withDefaults(
formatLabel?: (item: T) => string
neverEmpty?: boolean
capitalize?: boolean
size?: 'standard' | 'small'
}>(),
{
neverEmpty: true,
// Intentional any type, as this default should only be used for primitives (string or number)
formatLabel: (item) => item.toString(),
capitalize: true,
size: 'standard',
},
)
const selected = defineModel<T | null>()
// If one always has to be selected, default to the first one

View File

@@ -1,29 +1,27 @@
<template>
<div
class="relative overflow-hidden rounded-xl border-[2px] border-solid border-divider shadow-lg"
:class="{ 'max-h-32': isCollapsed }"
>
<div class="relative overflow-hidden">
<div
class="px-4 pt-4"
:class="{
'content-disabled pb-16': isCollapsed,
'pb-4': !isCollapsed,
}"
class="collapsible-region-content"
:class="{ open: !collapsed }"
:style="{ '--collapsed-height': collapsedHeight }"
>
<slot />
<div :class="{ 'pointer-events-none select-none pb-16': collapsed }">
<slot />
</div>
</div>
<div
v-if="isCollapsed"
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent to-button-bg"
></div>
v-if="collapsed"
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent"
:class="gradientTo"
/>
<div class="absolute bottom-4 left-1/2 z-20 -translate-x-1/2">
<ButtonStyled circular type="transparent">
<button class="flex items-center gap-1 text-xs" @click="toggleCollapsed">
<ExpandIcon v-if="isCollapsed" />
<button class="flex items-center gap-1 text-xs" @click="collapsed = !collapsed">
<ExpandIcon v-if="collapsed" />
<CollapseIcon v-else />
{{ isCollapsed ? expandText : collapseText }}
{{ collapsed ? expandText : collapseText }}
</button>
</ButtonStyled>
</div>
@@ -32,67 +30,51 @@
<script setup lang="ts">
import { CollapseIcon, ExpandIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ButtonStyled from './ButtonStyled.vue'
const props = withDefaults(
withDefaults(
defineProps<{
initiallyCollapsed?: boolean
expandText?: string
collapseText?: string
collapsedHeight?: string
gradientTo?: string
}>(),
{
initiallyCollapsed: true,
expandText: 'Expand',
collapseText: 'Collapse',
collapsedHeight: '8rem',
gradientTo: 'to-surface-2',
},
)
const isCollapsed = ref(props.initiallyCollapsed)
function toggleCollapsed() {
isCollapsed.value = !isCollapsed.value
}
function setCollapsed(value: boolean) {
isCollapsed.value = value
}
defineExpose({
isCollapsed,
setCollapsed,
toggleCollapsed,
})
const collapsed = defineModel<boolean>('collapsed', { default: true })
</script>
<style lang="scss" scoped>
.content-disabled {
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
<style scoped>
.collapsible-region-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s linear;
}
:deep(*) {
pointer-events: none !important;
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
}
:deep(button),
:deep(input),
:deep(textarea),
:deep(select),
:deep(a),
:deep([tabindex]) {
tabindex: -1 !important;
}
:deep(*:focus) {
outline: none !important;
@media (prefers-reduced-motion) {
.collapsible-region-content {
transition: none !important;
}
}
.collapsible-region-content.open {
grid-template-rows: 1fr;
}
.collapsible-region-content > div {
overflow: hidden;
min-height: var(--collapsed-height);
transition: min-height 0.3s linear;
}
.collapsible-region-content.open > div {
min-height: 0;
}
</style>

View File

@@ -4,7 +4,7 @@
ref="triggerRef"
role="button"
tabindex="0"
class="max-h-[36px] relative cursor-pointer flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-button-bg px-4 py-2.5 text-left transition-all duration-200 text-button-text hover:bg-button-bgHover active:bg-button-bgActive"
class="relative cursor-pointer flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-button-bg px-4 py-2.5 text-left transition-all duration-200 text-button-text hover:bg-button-bgHover active:bg-button-bgActive"
:class="[
triggerClasses,
{
@@ -61,6 +61,7 @@
:placeholder="searchPlaceholder"
class=""
@keydown.stop="handleSearchKeydown"
@input="emit('searchInput', searchQuery)"
/>
</div>
</div>
@@ -107,7 +108,7 @@
</div>
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
No results found
{{ noOptionsMessage }}
</div>
</div>
</Teleport>
@@ -128,7 +129,7 @@ import {
watch,
} from 'vue'
export interface DropdownOption<T> {
export interface ComboboxOption<T> {
value: T
label: string
icon?: Component
@@ -144,19 +145,19 @@ const DROPDOWN_VIEWPORT_MARGIN = 8
const DEFAULT_MAX_HEIGHT = 300
function isDropdownOption<T>(
opt: DropdownOption<T> | { type: 'divider' },
): opt is DropdownOption<T> {
opt: ComboboxOption<T> | { type: 'divider' },
): opt is ComboboxOption<T> {
return 'value' in opt
}
function isDivider<T>(opt: DropdownOption<T> | { type: 'divider' }): opt is { type: 'divider' } {
function isDivider<T>(opt: ComboboxOption<T> | { type: 'divider' }): opt is { type: 'divider' } {
return opt.type === 'divider'
}
const props = withDefaults(
defineProps<{
modelValue?: T
options: (DropdownOption<T> | { type: 'divider' })[]
options: (ComboboxOption<T> | { type: 'divider' })[]
placeholder?: string
disabled?: boolean
searchable?: boolean
@@ -168,6 +169,8 @@ const props = withDefaults(
extraPosition?: 'top' | 'bottom'
triggerClass?: string
forceDirection?: 'up' | 'down'
noOptionsMessage?: string
disableSearchFilter?: boolean
}>(),
{
placeholder: 'Select an option',
@@ -178,14 +181,16 @@ const props = withDefaults(
showChevron: true,
maxHeight: DEFAULT_MAX_HEIGHT,
extraPosition: 'bottom',
noOptionsMessage: 'No results found',
},
)
const emit = defineEmits<{
'update:modelValue': [value: T]
select: [option: DropdownOption<T>]
select: [option: ComboboxOption<T>]
open: []
close: []
searchInput: [query: string]
}>()
const slots = useSlots()
@@ -199,6 +204,7 @@ const dropdownRef = ref<HTMLElement>()
const searchInputRef = ref<HTMLInputElement>()
const optionsContainerRef = ref<HTMLElement>()
const optionRefs = ref<(HTMLElement | null)[]>([])
const rafId = ref<number | null>(null)
const dropdownStyle = ref({
top: '0px',
@@ -220,9 +226,9 @@ const triggerClasses = computed(() => {
return classes
})
const selectedOption = computed<DropdownOption<T> | undefined>(() => {
const selectedOption = computed<ComboboxOption<T> | undefined>(() => {
return props.options.find(
(opt): opt is DropdownOption<T> => isDropdownOption(opt) && opt.value === props.modelValue,
(opt): opt is ComboboxOption<T> => isDropdownOption(opt) && opt.value === props.modelValue,
)
})
@@ -240,7 +246,7 @@ const optionsWithKeys = computed(() => {
})
const filteredOptions = computed(() => {
if (!searchQuery.value || !props.searchable) {
if (!searchQuery.value || !props.searchable || props.disableSearchFilter) {
return optionsWithKeys.value
}
@@ -254,7 +260,7 @@ const filteredOptions = computed(() => {
const shouldRoundBottomCorners = computed(() => isOpen.value && openDirection.value === 'down')
const shouldRoundTopCorners = computed(() => isOpen.value && openDirection.value === 'up')
function getOptionClasses(item: DropdownOption<T> & { key: string }, index: number) {
function getOptionClasses(item: ComboboxOption<T> & { key: string }, index: number) {
return [
item.class,
{
@@ -363,11 +369,13 @@ async function openDropdown() {
setInitialFocus()
focusSearchInput()
startPositionTracking()
}
function closeDropdown() {
if (!isOpen.value) return
stopPositionTracking()
isOpen.value = false
searchQuery.value = ''
focusedIndex.value = -1
@@ -386,7 +394,7 @@ function handleTriggerClick() {
}
}
function handleOptionClick(option: DropdownOption<T>, index: number) {
function handleOptionClick(option: ComboboxOption<T>, index: number) {
if (option.disabled || option.type === 'divider') return
focusedIndex.value = index
@@ -509,6 +517,21 @@ function handleWindowResize() {
}
}
function startPositionTracking() {
function track() {
updateDropdownPosition()
rafId.value = requestAnimationFrame(track)
}
rafId.value = requestAnimationFrame(track)
}
function stopPositionTracking() {
if (rafId.value !== null) {
cancelAnimationFrame(rafId.value)
rafId.value = null
}
}
onClickOutside(
dropdownRef,
() => {
@@ -523,6 +546,7 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('resize', handleWindowResize)
stopPositionTracking()
})
watch(isOpen, (value) => {

View File

@@ -0,0 +1,122 @@
<template>
<label
:class="[
'flex flex-col items-center justify-center cursor-pointer border-2 border-dashed bg-surface-4 text-contrast transition-colors',
size === 'small' ? 'p-5' : 'p-12',
size === 'small' ? 'gap-2' : 'gap-4',
size === 'small' ? 'rounded-2xl' : 'rounded-3xl',
isDragOver ? 'border-purple' : 'border-surface-5',
]"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="handleDrop"
>
<div
:class="[
'grid place-content-center text-brand border-brand border-solid border bg-highlight-green',
size === 'small' ? 'w-10 h-10' : 'h-14 w-14',
size === 'small' ? 'rounded-xl' : 'rounded-2xl',
]"
>
<FolderUpIcon
aria-hidden="true"
:class="['text-brand', size === 'small' ? 'w-6 h-6' : 'w-8 h-8']"
/>
</div>
<div class="flex flex-col items-center justify-center gap-1 text-contrast text-center">
<div class="text-contrast font-medium text-pretty">
{{ primaryPrompt }}
</div>
<span class="text-primary text-sm text-pretty">
{{ secondaryPrompt }}
</span>
</div>
<input
ref="fileInput"
type="file"
:multiple="multiple"
:accept="accept"
:disabled="disabled"
class="hidden"
@change="handleChange"
/>
</label>
</template>
<script setup lang="ts">
import { FolderUpIcon } from '@modrinth/assets'
import { fileIsValid } from '@modrinth/utils'
import { ref } from 'vue'
const fileInput = ref<HTMLInputElement | null>(null)
const emit = defineEmits<{
(e: 'change', files: File[]): void
}>()
const props = withDefaults(
defineProps<{
prompt?: string
primaryPrompt?: string | null
secondaryPrompt?: string | null
multiple?: boolean
accept?: string
maxSize?: number | null
shouldAlwaysReset?: boolean
disabled?: boolean
size?: 'small' | 'standard'
}>(),
{
prompt: 'Drag and drop files or click to browse',
primaryPrompt: 'Drag and drop files or click to browse',
secondaryPrompt: 'You can try to drag files or folder or click this area to select it',
size: 'standard',
},
)
const files = ref<File[]>([])
function addFiles(incoming: FileList, shouldNotReset = false) {
if (!shouldNotReset || props.shouldAlwaysReset) {
files.value = Array.from(incoming)
}
const validationOptions = {
maxSize: props.maxSize ?? undefined,
alertOnInvalid: true,
}
files.value = files.value.filter((file) => fileIsValid(file, validationOptions))
if (files.value.length > 0) {
emit('change', files.value)
}
if (fileInput.value) fileInput.value.value = ''
}
const isDragOver = ref(false)
function onDragOver() {
isDragOver.value = true
}
function onDragLeave() {
isDragOver.value = false
}
function handleDrop(e: DragEvent) {
isDragOver.value = false
if (!e.dataTransfer) return
addFiles(e.dataTransfer.files)
}
function handleChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
addFiles(input.files)
}
</script>

View File

@@ -107,7 +107,7 @@ label {
grid-gap: 0.5rem;
background-color: var(--color-button-bg);
border-radius: var(--radius-sm);
border: dashed 0.3rem var(--color-contrast);
border: dashed 2px var(--color-contrast);
cursor: pointer;
color: var(--color-contrast);
}

View File

@@ -0,0 +1,3 @@
<template>
<div class="h-[1px] w-full bg-divider"></div>
</template>

View File

@@ -1,5 +1,5 @@
<template>
<Modal ref="linkModal" header="Insert link">
<NewModal ref="linkModal" header="Insert link">
<div class="modal-insert">
<label class="label" for="insert-link-label">
<span class="label__title">Label</span>
@@ -59,8 +59,8 @@
>
</div>
</div>
</Modal>
<Modal ref="imageModal" header="Insert image">
</NewModal>
<NewModal ref="imageModal" header="Insert image">
<div class="modal-insert">
<label class="label" for="insert-image-alt">
<span class="label__title">Description (alt text)<span class="required">*</span></span>
@@ -147,8 +147,8 @@
</Button>
</div>
</div>
</Modal>
<Modal ref="videoModal" header="Insert YouTube video">
</NewModal>
<NewModal ref="videoModal" header="Insert YouTube video">
<div class="modal-insert">
<label class="label" for="insert-video-url">
<span class="label__title">YouTube video URL<span class="required">*</span></span>
@@ -201,7 +201,7 @@
</Button>
</div>
</div>
</Modal>
</NewModal>
<div class="resizable-textarea-wrapper">
<div class="editor-action-row">
<div class="editor-actions">
@@ -223,10 +223,10 @@
</Button>
</template>
</template>
</div>
<div class="preview">
<Toggle id="preview" v-model="previewMode" />
<label class="label" for="preview"> Preview </label>
<div class="preview">
<Toggle id="preview" v-model="previewMode" />
<label class="label" for="preview"> Preview </label>
</div>
</div>
</div>
<div ref="editorRef" :class="{ hide: previewMode }" />
@@ -292,11 +292,11 @@ import {
XIcon,
YouTubeIcon,
} from '@modrinth/assets'
import NewModal from '@modrinth/ui/src/components/modal/NewModal.vue'
import { markdownCommands, modrinthMarkdownEditorKeymap } from '@modrinth/utils/codemirror'
import { renderHighlightedString } from '@modrinth/utils/highlightjs'
import { type Component, computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'
import Modal from '../modal/Modal.vue'
import Button from './Button.vue'
import Chips from './Chips.vue'
import FileInput from './FileInput.vue'
@@ -756,9 +756,9 @@ const videoMarkdown = computed(() => {
return ''
})
const linkModal = ref<InstanceType<typeof Modal> | null>(null)
const imageModal = ref<InstanceType<typeof Modal> | null>(null)
const videoModal = ref<InstanceType<typeof Modal> | null>(null)
const linkModal = ref<InstanceType<typeof NewModal> | null>(null)
const imageModal = ref<InstanceType<typeof NewModal> | null>(null)
const videoModal = ref<InstanceType<typeof NewModal> | null>(null)
function resetModalStates() {
linkText.value = ''

View File

@@ -0,0 +1,231 @@
<template>
<NewModal
ref="modal"
:scrollable="true"
max-content-height="72vh"
:on-hide="onModalHide"
:closable="true"
:close-on-click-outside="false"
>
<template #title>
<div class="flex flex-wrap items-center gap-1 text-secondary">
<span class="text-lg font-bold text-contrast sm:text-xl">{{ resolvedTitle }}</span>
</div>
</template>
<progress
v-if="nonProgressStage !== true"
:value="progressValue"
max="100"
class="w-full h-1 appearance-none border-none absolute top-0 left-0"
></progress>
<component :is="currentStage?.stageContent" />
<template #actions>
<div
class="flex flex-col justify-end gap-2 sm:flex-row"
:class="leftButtonConfig || rightButtonConfig ? 'mt-4' : ''"
>
<ButtonStyled v-if="leftButtonConfig" type="outlined">
<button
class="!border-surface-5"
:disabled="leftButtonConfig.disabled"
@click="leftButtonConfig.onClick"
>
<component :is="leftButtonConfig.icon" />
{{ leftButtonConfig.label }}
</button>
</ButtonStyled>
<ButtonStyled v-if="rightButtonConfig" :color="rightButtonConfig.color">
<button :disabled="rightButtonConfig.disabled" @click="rightButtonConfig.onClick">
<component
:is="rightButtonConfig.icon"
v-if="rightButtonConfig.iconPosition === 'before'"
:class="rightButtonConfig.iconClass"
/>
{{ rightButtonConfig.label }}
<component
:is="rightButtonConfig.icon"
v-if="rightButtonConfig.iconPosition === 'after'"
:class="rightButtonConfig.iconClass"
/>
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script lang="ts">
import { ButtonStyled, NewModal } from '@modrinth/ui'
import type { Component } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
export interface StageButtonConfig {
label?: string
icon?: Component | null
iconPosition?: 'before' | 'after'
color?: InstanceType<typeof ButtonStyled>['$props']['color']
disabled?: boolean
iconClass?: string | null
onClick?: () => void
}
export type MaybeCtxFn<T, R> = R | ((ctx: T) => R)
export interface StageConfigInput<T> {
id: string
stageContent: Component
title: MaybeCtxFn<T, string>
skip?: MaybeCtxFn<T, boolean>
nonProgressStage?: MaybeCtxFn<T, boolean>
leftButtonConfig: MaybeCtxFn<T, StageButtonConfig | null>
rightButtonConfig: MaybeCtxFn<T, StageButtonConfig | null>
}
export function resolveCtxFn<T, R>(value: MaybeCtxFn<T, R>, ctx: T): R {
return typeof value === 'function' ? (value as (ctx: T) => R)(ctx) : value
}
</script>
<script setup lang="ts" generic="T">
const props = defineProps<{
stages: StageConfigInput<T>[]
context: T
}>()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const currentStageIndex = ref<number>(0)
function show() {
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
const setStage = (indexOrId: number | string) => {
let index: number = 0
if (typeof indexOrId === 'number') {
index = indexOrId
if (index < 0 || index >= props.stages.length) return
} else {
index = props.stages.findIndex((stage) => stage.id === indexOrId)
if (index === -1) return
}
while (index < props.stages.length) {
const skip = props.stages[index]?.skip
if (!skip || !resolveCtxFn(skip, props.context)) break
index++
}
if (index < props.stages.length) {
currentStageIndex.value = index
}
}
const nextStage = () => {
if (currentStageIndex.value === -1) return
if (currentStageIndex.value >= props.stages.length - 1) return
let nextIndex = currentStageIndex.value + 1
while (nextIndex < props.stages.length) {
const skip = props.stages[nextIndex]?.skip
if (!skip || !resolveCtxFn(skip, props.context)) break
nextIndex++
}
if (nextIndex < props.stages.length) {
currentStageIndex.value = nextIndex
}
}
const prevStage = () => {
if (currentStageIndex.value <= 0) return
let prevIndex = currentStageIndex.value - 1
while (prevIndex >= 0) {
const skip = props.stages[prevIndex]?.skip
if (!skip || !resolveCtxFn(skip, props.context)) break
prevIndex--
}
if (prevIndex >= 0) {
currentStageIndex.value = prevIndex
}
}
const currentStage = computed(() => props.stages[currentStageIndex.value])
const resolvedTitle = computed(() => {
const stage = currentStage.value
if (!stage) return ''
return resolveCtxFn(stage.title, props.context)
})
const leftButtonConfig = computed(() => {
const stage = currentStage.value
if (!stage) return null
return resolveCtxFn(stage.leftButtonConfig, props.context)
})
const rightButtonConfig = computed(() => {
const stage = currentStage.value
if (!stage) return null
return resolveCtxFn(stage.rightButtonConfig, props.context)
})
const nonProgressStage = computed(() => {
const stage = currentStage.value
if (!stage) return false
return resolveCtxFn(stage.nonProgressStage, props.context)
})
const progressValue = computed(() => {
const isProgressStage = (stage: StageConfigInput<T>) => {
if (resolveCtxFn(stage.nonProgressStage, props.context)) return false
const skip = stage.skip ? resolveCtxFn(stage.skip, props.context) : false
return !skip
}
const completedCount = props.stages
.slice(0, currentStageIndex.value + 1)
.filter(isProgressStage).length
const totalCount = props.stages.filter(isProgressStage).length
return totalCount > 0 ? (completedCount / totalCount) * 100 : 0
})
const emit = defineEmits<{
(e: 'refresh-data' | 'hide'): void
}>()
function onModalHide() {
emit('hide')
}
defineExpose({
show,
hide,
setStage,
nextStage,
prevStage,
currentStageIndex,
})
</script>
<style scoped>
progress {
@apply bg-surface-3;
background-color: var(--surface-3, rgb(30, 30, 30));
}
progress::-webkit-progress-bar {
@apply bg-surface-3;
}
progress::-webkit-progress-value {
@apply bg-contrast;
}
progress::-moz-progress-bar {
@apply bg-contrast;
}
</style>

View File

@@ -22,23 +22,30 @@
}"
class="page-number-container"
>
<div v-if="item === '-'">
<GapIcon />
<div v-if="item === '-'" class="rotate-90 grid place-content-center">
<EllipsisVerticalIcon />
</div>
<ButtonStyled
v-else
circular
:color="page === item ? 'brand' : 'standard'"
:type="page === item ? 'standard' : 'transparent'"
:type="page === item ? 'highlight' : 'transparent'"
>
<a
v-if="linkFunction"
:href="linkFunction(item)"
:class="page === item ? '!text-brand' : ''"
@click.prevent="page !== item ? switchPage(item) : null"
>
{{ item }}
</a>
<button v-else @click="page !== item ? switchPage(item) : null">{{ item }}</button>
<button
v-else
:class="page === item ? '!text-brand' : ''"
@click="page !== item ? switchPage(item) : null"
>
{{ item }}
</button>
</ButtonStyled>
</div>
@@ -58,7 +65,7 @@
</div>
</template>
<script setup lang="ts">
import { ChevronLeftIcon, ChevronRightIcon, GapIcon } from '@modrinth/assets'
import { ChevronLeftIcon, ChevronRightIcon, EllipsisVerticalIcon } from '@modrinth/assets'
import { computed } from 'vue'
import ButtonStyled from './ButtonStyled.vue'

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import SpinnerIcon from '@modrinth/assets/icons/spinner.svg'
import { computed } from 'vue'
const props = withDefaults(
@@ -7,11 +8,21 @@ const props = withDefaults(
max?: number
color?: 'brand' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'gray'
waiting?: boolean
fullWidth?: boolean
striped?: boolean
gradientBorder?: boolean
label?: string
labelClass?: string
showProgress?: boolean
}>(),
{
max: 1,
color: 'brand',
waiting: false,
fullWidth: false,
striped: false,
gradientBorder: true,
showProgress: false,
},
)
@@ -49,15 +60,28 @@ const colors = {
const percent = computed(() => props.progress / props.max)
</script>
<template>
<div
class="flex w-full max-w-[15rem] h-1 rounded-full overflow-hidden"
:class="colors[props.color].bg"
>
<div
class="rounded-full progress-bar"
:class="[colors[props.color].fg, { 'progress-bar--waiting': waiting }]"
:style="!waiting ? { width: `${percent * 100}%` } : {}"
></div>
<div class="flex w-full flex-col gap-2" :class="fullWidth ? '' : 'max-w-[15rem]'">
<div v-if="label || showProgress" class="flex items-center justify-between">
<span v-if="label" :class="labelClass">{{ label }}</span>
<div v-if="showProgress" class="flex items-center gap-1 text-sm text-secondary">
<span>{{ Math.round(percent * 100) }}%</span>
<slot name="progress-icon">
<SpinnerIcon class="size-5 animate-spin" />
</slot>
</div>
</div>
<div class="flex h-2 w-full overflow-hidden rounded-full" :class="[colors[props.color].bg]">
<div
class="rounded-full progress-bar"
:class="[
colors[props.color].fg,
{ 'progress-bar--waiting': waiting },
{ 'progress-bar--gradient-border': gradientBorder },
striped ? `progress-bar--striped--${color}` : '',
]"
:style="!waiting ? { width: `${percent * 100}%` } : {}"
></div>
</div>
</div>
</template>
<style scoped lang="scss">
@@ -83,4 +107,76 @@ const percent = computed(() => props.progress / props.max)
width: 20%;
}
}
.progress-bar--gradient-border {
position: relative;
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.3), transparent);
border-radius: inherit;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: xor;
padding: 2px;
pointer-events: none;
}
}
%progress-bar--striped-common {
background-attachment: scroll;
background-position: 0 0;
background-size: 9.38px 9.38px;
}
@mixin striped-background($color-variable) {
background-image: linear-gradient(
135deg,
$color-variable 11.54%,
transparent 11.54%,
transparent 50%,
$color-variable 50%,
$color-variable 61.54%,
transparent 61.54%,
transparent 100%
);
}
.progress-bar--striped--brand {
@include striped-background(var(--color-brand));
@extend %progress-bar--striped-common;
}
.progress-bar--striped--green {
@include striped-background(var(--color-green));
@extend %progress-bar--striped-common;
}
.progress-bar--striped--red {
@include striped-background(var(--color-red));
@extend %progress-bar--striped-common;
}
.progress-bar--striped--orange {
@include striped-background(var(--color-orange));
@extend %progress-bar--striped-common;
}
.progress-bar--striped--blue {
@include striped-background(var(--color-blue));
@extend %progress-bar--striped-common;
}
.progress-bar--striped--purple {
@include striped-background(var(--color-purple));
@extend %progress-bar--striped-common;
}
.progress-bar--striped--gray {
@include striped-background(var(--color-divider-dark));
@extend %progress-bar--striped-common;
}
</style>

View File

@@ -1,9 +1,9 @@
<template>
<div class="flex flex-wrap gap-2">
<div class="flex flex-col gap-1">
<button
v-for="(item, index) in items"
:key="`radio-button-${index}`"
class="p-0 py-2 px-2 border-0 font-medium flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
class="p-0 py-2 px-2 w-fit border-0 font-medium flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
:class="{
'text-contrast bg-button-bg': selected === item,
'text-primary bg-transparent': selected !== item,

View File

@@ -55,7 +55,6 @@ onUnmounted(() => {
}
})
function updateFade(scrollTop, offsetHeight, scrollHeight) {
console.log(scrollTop, offsetHeight, scrollHeight)
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
scrollableAtTop.value = scrollTop <= 0
}

View File

@@ -18,12 +18,12 @@
</template>
</template>
<template #actions>
<ButtonStyled v-if="dismissable" circular>
<ButtonStyled v-if="dismissable" :color="NOTICE_TYPE_BTN[level]">
<button
v-tooltip="formatMessage(messages.dismiss)"
@click="() => (preview ? {} : emit('dismiss'))"
>
<XIcon />
<XIcon /> Dismiss
</button>
</ButtonStyled>
</template>
@@ -91,6 +91,12 @@ const NOTICE_TYPE: Record<string, 'info' | 'warning' | 'critical'> = {
critical: 'critical',
}
const NOTICE_TYPE_BTN: Record<string, 'blue' | 'orange' | 'red'> = {
info: 'blue',
warn: 'orange',
critical: 'red',
}
const heading = computed(() => NOTICE_HEADINGS[props.level] ?? messages.info)
</script>
<style scoped lang="scss">

View File

@@ -1,27 +0,0 @@
<template>
<div class="flex items-center gap-3">
<slot></slot>
<div class="flex flex-col">
<span class="font-bold">{{ value }}</span>
<span class="text-secondary">{{ label }}</span>
</div>
</div>
</template>
<script setup>
defineProps({
label: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
})
</script>
<style scoped>
:slotted(*) {
@apply h-6 w-6 text-secondary;
}
</style>

View File

@@ -0,0 +1,61 @@
export { default as Accordion } from './Accordion.vue'
export { default as Admonition } from './Admonition.vue'
export { default as AppearingProgressBar } from './AppearingProgressBar.vue'
export { default as AutoBrandIcon } from './AutoBrandIcon.vue'
export { default as AutoLink } from './AutoLink.vue'
export { default as Avatar } from './Avatar.vue'
export { default as Badge } from './Badge.vue'
export { default as BulletDivider } from './BulletDivider.vue'
export { default as Button } from './Button.vue'
export { default as ButtonStyled } from './ButtonStyled.vue'
export { default as Card } from './Card.vue'
export { default as Checkbox } from './Checkbox.vue'
export { default as Chips } from './Chips.vue'
export { default as Collapsible } from './Collapsible.vue'
export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
export type { ComboboxOption } from './Combobox.vue'
export { default as Combobox } from './Combobox.vue'
export { default as ContentPageHeader } from './ContentPageHeader.vue'
export { default as CopyCode } from './CopyCode.vue'
export { default as DoubleIcon } from './DoubleIcon.vue'
export { default as DropArea } from './DropArea.vue'
export { default as DropdownSelect } from './DropdownSelect.vue'
export { default as DropzoneFileInput } from './DropzoneFileInput.vue'
export { default as EnvironmentIndicator } from './EnvironmentIndicator.vue'
export { default as ErrorInformationCard } from './ErrorInformationCard.vue'
export { default as FileInput } from './FileInput.vue'
export type { FilterBarOption } from './FilterBar.vue'
export { default as FilterBar } from './FilterBar.vue'
export { default as HeadingLink } from './HeadingLink.vue'
export { default as HorizontalRule } from './HorizontalRule.vue'
export { default as IconSelect } from './IconSelect.vue'
export type { JoinedButtonAction } from './JoinedButtons.vue'
export { default as JoinedButtons } from './JoinedButtons.vue'
export { default as LoadingIndicator } from './LoadingIndicator.vue'
export { default as ManySelect } from './ManySelect.vue'
export { default as MarkdownEditor } from './MarkdownEditor.vue'
export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiStageModal.vue'
export { default as MultiStageModal } from './MultiStageModal.vue'
export { resolveCtxFn } from './MultiStageModal.vue'
export { default as OptionGroup } from './OptionGroup.vue'
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
export { default as OverflowMenu } from './OverflowMenu.vue'
export { default as Page } from './Page.vue'
export { default as Pagination } from './Pagination.vue'
export { default as PopoutMenu } from './PopoutMenu.vue'
export { default as PreviewSelectButton } from './PreviewSelectButton.vue'
export { default as ProgressBar } from './ProgressBar.vue'
export { default as ProgressSpinner } from './ProgressSpinner.vue'
export { default as ProjectCard } from './ProjectCard.vue'
export { default as RadialHeader } from './RadialHeader.vue'
export { default as RadioButtons } from './RadioButtons.vue'
export { default as ScrollablePanel } from './ScrollablePanel.vue'
export { default as ServerNotice } from './ServerNotice.vue'
export { default as SettingsLabel } from './SettingsLabel.vue'
export { default as SimpleBadge } from './SimpleBadge.vue'
export { default as Slider } from './Slider.vue'
export { default as SmartClickable } from './SmartClickable.vue'
export { default as TagItem } from './TagItem.vue'
export { default as Timeline } from './Timeline.vue'
export { default as Toggle } from './Toggle.vue'
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { CardIcon, CurrencyIcon, PayPalIcon, UnknownIcon } from '@modrinth/assets'
import { useVIntl } from '@vintl/vintl'
import type Stripe from 'stripe'
import { commonMessages, paymentMethodMessages } from '../../utils'
import { commonMessages, getPaymentMethodIcon, paymentMethodMessages } from '../../utils'
const { formatMessage } = useVIntl()
defineProps<{
@@ -13,10 +12,7 @@ defineProps<{
<template>
<template v-if="'type' in method">
<CardIcon v-if="method.type === 'card'" class="size-[1.5em]" />
<CurrencyIcon v-else-if="method.type === 'cashapp'" class="size-[1.5em]" />
<PayPalIcon v-else-if="method.type === 'paypal'" class="size-[1.5em]" />
<UnknownIcon v-else class="size-[1.5em]" />
<component :is="getPaymentMethodIcon(method.type)" class="size-[1.5em]" />
<span v-if="method.type === 'card' && 'card' in method && method.card">
{{
formatMessage(commonMessages.paymentMethodCardDisplay, {

View File

@@ -5,7 +5,7 @@
<template v-if="productType === 'midas'">Subscribe to Modrinth+!</template>
<template v-else-if="productType === 'pyro'">
<template v-if="existingSubscription"> Upgrade server plan </template>
<template v-else> Subscribe to Modrinth Servers! </template>
<template v-else> Subscribe to Modrinth Hosting! </template>
</template>
<template v-else>Purchase product</template>
</span>
@@ -143,7 +143,7 @@
Max Burst CPUs
<UnknownIcon
v-tooltip="
'CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. See Modrinth Servers FAQ for more info.'
'CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. See Modrinth Hosting FAQ for more info.'
"
class="h-4 w-4text-secondary opacity-60"
/>
@@ -292,7 +292,7 @@
<p class="my-2 text-lg font-bold text-primary">Purchase details</p>
<div class="mb-2 flex justify-between">
<span class="text-secondary">
{{ mutatedProduct.metadata.type === 'midas' ? 'Modrinth+' : 'Modrinth Servers' }}
{{ mutatedProduct.metadata.type === 'midas' ? 'Modrinth+' : 'Modrinth Hosting' }}
{{
existingPlan
? `(${dayjs(renewalDate).diff(dayjs(), 'days')} days prorated)`

View File

@@ -170,7 +170,7 @@ const messages = defineMessages({
},
regionUnsupported: {
id: 'servers.region.region-unsupported',
defaultMessage: `Region not listed? <link>Let us know where you'd like to see Modrinth Servers next!</link>`,
defaultMessage: `Region not listed? <link>Let us know where you'd like to see Modrinth Hosting next!</link>`,
},
customPrompt: {
id: 'servers.region.custom.prompt',

View File

@@ -295,10 +295,10 @@ function setInterval(newInterval: ServerBillingInterval) {
{
title:
isProratedCharge && prorationDays
? `Modrinth Servers (${planName}) prorated for ${prorationDays} day${
? `Modrinth Hosting (${planName}) prorated for ${prorationDays} day${
prorationDays === 1 ? '' : 's'
}`
: `Modrinth Servers (${planName})`,
: `Modrinth Hosting (${planName})`,
amount: total - tax,
},
{

Some files were not shown because too many files have changed in this diff Show More