Merge tag 'v0.10.24' into beta
@@ -8,6 +8,7 @@
|
||||
"fix": "eslint . --fix && prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"ofetch": "^1.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
104
packages/api-client/src/core/abstract-websocket.ts
Normal 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
|
||||
}
|
||||
}
|
||||
18
packages/api-client/src/features/panel-version.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
95
packages/api-client/src/modules/archon/backups/v0.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
132
packages/api-client/src/modules/archon/backups/v1.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
128
packages/api-client/src/modules/labrinth/collections.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
124
packages/api-client/src/modules/labrinth/tech-review/internal.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
290
packages/api-client/src/modules/labrinth/versions/v3.ts
Normal 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 version’s 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': '',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
149
packages/api-client/src/platform/websocket-generic.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -336,6 +336,11 @@ pub struct VersionFile {
|
||||
pub enum FileType {
|
||||
RequiredResourcePack,
|
||||
OptionalResourcePack,
|
||||
SourcesJar,
|
||||
DevJar,
|
||||
JavadocJar,
|
||||
Signature,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
14
packages/assets/icons/bug.svg
Normal 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 |
4
packages/assets/icons/chevron-down.svg
Normal 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 |
8
packages/assets/icons/file-code.svg
Normal 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 |
9
packages/assets/icons/file-image.svg
Normal 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 |
1
packages/assets/icons/folder-up.svg
Normal 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 |
5
packages/assets/icons/folder.svg
Normal 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 |
1
packages/assets/icons/heart-minus.svg
Normal 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 |
6
packages/assets/icons/list-filter.svg
Normal 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 |
18
packages/assets/icons/shield-alert.svg
Normal 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 |
7
packages/assets/icons/shield-check.svg
Normal 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 |
6
packages/assets/icons/user-round.svg
Normal 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 |
47
packages/assets/illustrations/empty.svg
Normal 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 |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
87
packages/blog/articles/streamlined-version-creation.md
Normal 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, we’re shipping a new project version creation and editing today. This part of the product was showing its age, so we’ve 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, we’ve 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 you’re the creator.
|
||||
|
||||

|
||||
|
||||
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 you’ve uploaded your files, you’re 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 we’re unable to detect something, that field simply won’t appear in the summary and will instead show up as an additional step in the modal flow.
|
||||
|
||||

|
||||
|
||||
## Loader selector
|
||||
|
||||
We’ve 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. We’ve 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.
|
||||
|
||||

|
||||
|
||||
## Dependency selector
|
||||
|
||||
Dependencies were another pain point, so we’ve added the ability to search projects and versions directly, no more copying IDs. We also suggest dependencies from the other versions you’ve 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! 🎅
|
||||
@@ -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,
|
||||
|
||||
12
packages/blog/compiled/streamlined_version_creation.ts
Normal 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,
|
||||
|
||||
};
|
||||
BIN
packages/blog/public/streamlined-version-creation/details.webp
Normal file
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 78 KiB |
BIN
packages/blog/public/streamlined-version-creation/thumbnail.webp
Normal file
|
After Width: | Height: | Size: 90 KiB |
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@modrinth/api-client": "workspace:*",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>>
|
||||
|
||||
11
packages/moderation/src/data/tech-review-quick-replies.ts
Normal 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>>
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
6
packages/moderation/src/types/quick-reply.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`] =
|
||||
|
||||
15
packages/modrinth-log/Cargo.toml
Normal 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
|
||||
1
packages/modrinth-log/README.md
Normal file
@@ -0,0 +1 @@
|
||||
Service logging utilities.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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));
|
||||
@@ -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 {
|
||||
122
packages/muralpay/src/client/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms.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(¶ms.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")]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¶ms.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(¶ms.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,
|
||||
}
|
||||
|
||||
@@ -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(¶ms.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(¶ms.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);
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
|
||||
138
packages/muralpay/src/transaction.rs
Normal 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(¶ms.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>,
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
2
packages/ui/src/components/affiliate/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as AffiliateLinkCard } from './AffiliateLinkCard.vue'
|
||||
export { default as AffiliateLinkCreateModal } from './AffiliateLinkCreateModal.vue'
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
122
packages/ui/src/components/base/DropzoneFileInput.vue
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
3
packages/ui/src/components/base/HorizontalRule.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div class="h-[1px] w-full bg-divider"></div>
|
||||
</template>
|
||||
@@ -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 = ''
|
||||
|
||||
231
packages/ui/src/components/base/MultiStageModal.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
61
packages/ui/src/components/base/index.ts
Normal 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'
|
||||
@@ -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, {
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||