diff --git a/.vscode/settings.json b/.vscode/settings.json index 7522198819..af3d483835 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,7 +9,7 @@ "files.insertFinalNewline": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", - "source.organizeImports": "always" + "source.organizeImports": "never" }, "editor.defaultFormatter": "esbenp.prettier-vscode", "[vue]": { diff --git a/apps/app-frontend/src/pages/hosting/manage/Access.vue b/apps/app-frontend/src/pages/hosting/manage/Access.vue new file mode 100644 index 0000000000..59a3b82e36 --- /dev/null +++ b/apps/app-frontend/src/pages/hosting/manage/Access.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/app-frontend/src/pages/hosting/manage/index.js b/apps/app-frontend/src/pages/hosting/manage/index.js index 50052e3f9e..0c10b07133 100644 --- a/apps/app-frontend/src/pages/hosting/manage/index.js +++ b/apps/app-frontend/src/pages/hosting/manage/index.js @@ -1,7 +1,8 @@ +import Access from './Access.vue' import Backups from './Backups.vue' import Content from './Content.vue' import Files from './Files.vue' import Index from './Index.vue' import Overview from './Overview.vue' -export { Backups, Content, Files, Index, Overview } +export { Access, Backups, Content, Files, Index, Overview } diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index c12306b5ad..2057d67e17 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -73,6 +73,14 @@ export default new createRouter({ breadcrumb: [{ name: '?Server' }], }, }, + { + path: 'access', + name: 'ServerManageAccess', + component: Hosting.Access, + meta: { + breadcrumb: [{ name: '?Server' }], + }, + }, ], }, { diff --git a/apps/frontend/CLAUDE.md b/apps/frontend/CLAUDE.md index 9d0d05c4df..03515cbb31 100644 --- a/apps/frontend/CLAUDE.md +++ b/apps/frontend/CLAUDE.md @@ -40,4 +40,3 @@ These composables are deprecated and should not be used in new code: - **`useAsyncData`** - we use tanstack, not nuxt's built in async data utility. - **`useBaseFetch`** (`src/composables/fetch.js`) — legacy Labrinth fetch wrapper. Use `client.labrinth.*` modules instead. -- **`useServersFetch`** (`src/composables/servers/servers-fetch.ts`) — legacy Archon fetch wrapper with manual retry/circuit-breaker. Use `client.archon.*` modules instead — refer to the `packages/api-client/CLAUDE.md` for more information. diff --git a/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue b/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue index 4a303112a0..a52bb5c01e 100644 --- a/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue +++ b/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue @@ -1,20 +1,22 @@ + + diff --git a/apps/frontend/src/templates/emails/index.ts b/apps/frontend/src/templates/emails/index.ts index 719611b64b..015c1fdeb9 100644 --- a/apps/frontend/src/templates/emails/index.ts +++ b/apps/frontend/src/templates/emails/index.ts @@ -32,6 +32,10 @@ export default { 'project-invited': () => import('./project/ProjectInvited.vue'), 'project-transferred': () => import('./project/ProjectTransferred.vue'), + // Server + 'server-invited': () => import('./server/ServerInvited.vue'), + 'server-invited-no-account': () => import('./server/ServerInvitedNoAccount.vue'), + // Organizations 'organization-invited': () => import('./organization/OrganizationInvited.vue'), } as Record Promise<{ default: Component }>> diff --git a/apps/frontend/src/templates/emails/server/ServerInvited.vue b/apps/frontend/src/templates/emails/server/ServerInvited.vue new file mode 100644 index 0000000000..e616d50eb3 --- /dev/null +++ b/apps/frontend/src/templates/emails/server/ServerInvited.vue @@ -0,0 +1,82 @@ + + + diff --git a/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue b/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue new file mode 100644 index 0000000000..07b6f7cbee --- /dev/null +++ b/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue @@ -0,0 +1,80 @@ + + + diff --git a/packages/api-client/src/core/abstract-client.ts b/packages/api-client/src/core/abstract-client.ts index 97ce38dd1b..7bcef2a215 100644 --- a/packages/api-client/src/core/abstract-client.ts +++ b/packages/api-client/src/core/abstract-client.ts @@ -1,6 +1,6 @@ import type { InferredClientModules } from '../modules' import { buildModuleStructure } from '../modules' -import type { ClientConfig } from '../types/client' +import type { BaseUrlConfig, ClientConfig } from '../types/client' import type { RequestContext, RequestOptions } from '../types/request' import type { UploadMetadata, UploadProgress, UploadRequestOptions } from '../types/upload' import type { AbstractFeature } from './abstract-feature' @@ -116,9 +116,9 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { async request(path: string, options: RequestOptions): Promise { let baseUrl: string if (options.api === 'labrinth') { - baseUrl = this.config.labrinthBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!) } else if (options.api === 'archon') { - baseUrl = this.config.archonBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!) } else { baseUrl = options.api } @@ -243,6 +243,10 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { return `${base}${versionPath}${cleanPath}` } + protected resolveBaseUrl(baseUrl: BaseUrlConfig): string { + return typeof baseUrl === 'function' ? baseUrl() : baseUrl + } + /** * Build the request context */ diff --git a/packages/api-client/src/modules/archon/actions/v1.ts b/packages/api-client/src/modules/archon/actions/v1.ts new file mode 100644 index 0000000000..a90f0319bb --- /dev/null +++ b/packages/api-client/src/modules/archon/actions/v1.ts @@ -0,0 +1,33 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonActionsV1Module extends AbstractModule { + public getModuleID(): string { + return 'archon_actions_v1' + } + + /** + * Get server action log entries. + * GET /v1/servers/:server_id/action-log + */ + public async list( + serverId: string, + options: Archon.Actions.v1.ListActionLogOptions = {}, + ): Promise { + const params: Record = {} + if (options.filter) params.filter = JSON.stringify(options.filter) + if (options.limit !== undefined) params.limit = options.limit + if (options.offset !== undefined) params.offset = options.offset + if (options.order !== undefined) params.order = options.order + + return this.client.request( + `/servers/${serverId}/action-log`, + { + api: 'archon', + version: 1, + method: 'GET', + params: Object.keys(params).length > 0 ? params : undefined, + }, + ) + } +} diff --git a/packages/api-client/src/modules/archon/index.ts b/packages/api-client/src/modules/archon/index.ts index ed9719ad3c..194ea0333e 100644 --- a/packages/api-client/src/modules/archon/index.ts +++ b/packages/api-client/src/modules/archon/index.ts @@ -1,3 +1,4 @@ +export * from './actions/v1' export * from './backups/v1' export * from './backups-queue/v1' export * from './content/v1' diff --git a/packages/api-client/src/modules/archon/nodes/internal.ts b/packages/api-client/src/modules/archon/nodes/internal.ts new file mode 100644 index 0000000000..254f392c8c --- /dev/null +++ b/packages/api-client/src/modules/archon/nodes/internal.ts @@ -0,0 +1,20 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonNodesInternalModule extends AbstractModule { + public getModuleID(): string { + return 'archon_nodes_internal' + } + + /** + * Get node hostnames and region summary for admin tooling. + * GET /_internal/nodes/overview + */ + public async overview(): Promise { + return this.client.request('/nodes/overview', { + api: 'archon', + version: 'internal', + method: 'GET', + }) + } +} diff --git a/packages/api-client/src/modules/archon/notices/v0.ts b/packages/api-client/src/modules/archon/notices/v0.ts new file mode 100644 index 0000000000..a6e76b0277 --- /dev/null +++ b/packages/api-client/src/modules/archon/notices/v0.ts @@ -0,0 +1,98 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonNoticesV0Module extends AbstractModule { + public getModuleID(): string { + return 'archon_notices_v0' + } + + /** + * Get all server notices. + * GET /modrinth/v0/notices + */ + public async list(): Promise { + return this.client.request('/notices', { + api: 'archon', + version: 'modrinth/v0', + method: 'GET', + }) + } + + /** + * Create a server notice. + * POST /modrinth/v0/notices + */ + public async create( + request: Archon.Notices.v0.Announce, + ): Promise { + return this.client.request('/notices', { + api: 'archon', + version: 'modrinth/v0', + method: 'POST', + body: request, + }) + } + + /** + * Update a server notice. + * PATCH /modrinth/v0/notices/:id + */ + public async update(id: number, request: Archon.Notices.v0.AnnouncePatch): Promise { + await this.client.request(`/notices/${id}`, { + api: 'archon', + version: 'modrinth/v0', + method: 'PATCH', + body: request, + }) + } + + /** + * Delete a server notice. + * DELETE /modrinth/v0/notices/:id + */ + public async delete(id: number): Promise { + await this.client.request(`/notices/${id}`, { + api: 'archon', + version: 'modrinth/v0', + method: 'DELETE', + }) + } + + /** + * Assign a notice to a server or node. + * PUT /modrinth/v0/notices/:id/assign?server=:serverId + * PUT /modrinth/v0/notices/:id/assign?node=:nodeId + */ + public async assign(id: number, target: Archon.Notices.v0.AssignmentTarget): Promise { + await this.client.request(`/notices/${id}/assign`, { + api: 'archon', + version: 'modrinth/v0', + method: 'PUT', + params: this.assignmentTargetToParams(target), + }) + } + + /** + * Unassign a notice from a server or node. + * PUT /modrinth/v0/notices/:id/unassign?server=:serverId + * PUT /modrinth/v0/notices/:id/unassign?node=:nodeId + */ + public async unassign(id: number, target: Archon.Notices.v0.AssignmentTarget): Promise { + await this.client.request(`/notices/${id}/unassign`, { + api: 'archon', + version: 'modrinth/v0', + method: 'PUT', + params: this.assignmentTargetToParams(target), + }) + } + + private assignmentTargetToParams( + target: Archon.Notices.v0.AssignmentTarget, + ): Record { + if ('server' in target) { + return { server: target.server } + } + + return { node: target.node } + } +} diff --git a/packages/api-client/src/modules/archon/server-users/v1.ts b/packages/api-client/src/modules/archon/server-users/v1.ts new file mode 100644 index 0000000000..658c9fca17 --- /dev/null +++ b/packages/api-client/src/modules/archon/server-users/v1.ts @@ -0,0 +1,65 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonServerUsersV1Module extends AbstractModule { + public getModuleID(): string { + return 'archon_server_users_v1' + } + + /** + * Get list of users with access to a server + * GET /v1/servers/:server_id/users + */ + public async list(serverId: string): Promise { + return this.client.request(`/servers/${serverId}/users`, { + api: 'archon', + version: 1, + method: 'GET', + }) + } + + /** + * Add a user to a server + * POST /v1/servers/:server_id/users + */ + public async add( + serverId: string, + user: Archon.ServerUsers.v1.AddServerUserRequest, + ): Promise { + await this.client.request(`/servers/${serverId}/users`, { + api: 'archon', + version: 1, + method: 'POST', + body: user, + }) + } + + /** + * Remove a user from a server + * DELETE /v1/servers/:server_id/users/:user_id + */ + public async delete(serverId: string, userId: string): Promise { + await this.client.request(`/servers/${serverId}/users/${userId}`, { + api: 'archon', + version: 1, + method: 'DELETE', + }) + } + + /** + * Update a user's server role + * PATCH /v1/servers/:server_id/users/:user_id + */ + public async update( + serverId: string, + userId: string, + role: Archon.ServerUsers.v1.AssignableServerUserRole, + ): Promise { + await this.client.request(`/servers/${serverId}/users/${userId}`, { + api: 'archon', + version: 1, + method: 'PATCH', + body: JSON.stringify(role), + }) + } +} diff --git a/packages/api-client/src/modules/archon/transfers/internal.ts b/packages/api-client/src/modules/archon/transfers/internal.ts new file mode 100644 index 0000000000..ccbedaffd8 --- /dev/null +++ b/packages/api-client/src/modules/archon/transfers/internal.ts @@ -0,0 +1,84 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonTransfersInternalModule extends AbstractModule { + public getModuleID(): string { + return 'archon_transfers_internal' + } + + /** + * Schedule transfers for specific servers. + * POST /_internal/transfers/schedule/servers + */ + public async scheduleServers( + request: Archon.Transfers.Internal.ScheduleServerTransfersRequest, + ): Promise { + return this.client.request( + '/transfers/schedule/servers', + { + api: 'archon', + version: 'internal', + method: 'POST', + body: request, + }, + ) + } + + /** + * Schedule transfers for all servers on specific nodes. + * POST /_internal/transfers/schedule/nodes + */ + public async scheduleNodes( + request: Archon.Transfers.Internal.ScheduleNodeTransfersRequest, + ): Promise { + return this.client.request( + '/transfers/schedule/nodes', + { + api: 'archon', + version: 'internal', + method: 'POST', + body: request, + }, + ) + } + + /** + * Get transfer batch history. + * GET /_internal/transfers/history + */ + public async history( + options?: Archon.Transfers.Internal.TransferHistoryQuery, + ): Promise { + const params: Record = {} + if (options?.page !== undefined) params.page = options.page + if (options?.page_size !== undefined) params.page_size = options.page_size + + return this.client.request( + '/transfers/history', + { + api: 'archon', + version: 'internal', + method: 'GET', + params, + }, + ) + } + + /** + * Cancel pending transfer batches. + * POST /_internal/transfers/cancel + */ + public async cancel( + request: Archon.Transfers.Internal.CancelTransfersRequest, + ): Promise { + return this.client.request( + '/transfers/cancel', + { + api: 'archon', + version: 'internal', + method: 'POST', + body: request, + }, + ) + } +} diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts index b6435c0be2..766f3100dd 100644 --- a/packages/api-client/src/modules/archon/types.ts +++ b/packages/api-client/src/modules/archon/types.ts @@ -1,6 +1,274 @@ import type { Labrinth } from '../labrinth/types' export namespace Archon { + export namespace Nodes { + export namespace Internal { + export type Node = { + id: string + hostname: string + region: string + created_at: string | null + locked: boolean + } + + export type Server = { + id: string + available: boolean + } + + export type NodeFull = Node & { + servers: Server[] + } + + export type Overview = { + node_hostnames: string[] + regions: Region[] + total_servers_active: number + } + + export type Region = { + display_name: string + country_code: string + key: string + server_count: number + node_count: number + } + + export type RegionWithStatistics = { + region: Region + active_servers: string[] + } + } + } + + export namespace Notices { + export namespace v0 { + export type Notice = { + id: number + dismissable: boolean + title: string | null + message: string + level: string + announced: string + } + + export type ListedNotice = { + id: number + dismissable: boolean + message: string + title: string | null + level: string + announce_at: string + expires: string | null + assigned: Assignment[] + dismissed_by: Dismisser[] + } + + export type Dismisser = { + server: string + dismissed_on: string + } + + export type Assignment = { + kind: string + id: string + name: string + } + + export type AssignmentTarget = { server: string } | { node: string } + + export type Announce = { + message: string + title?: string | null + level: string + dismissable: boolean + announce_at: string + expires?: string | null + } + + export type AnnouncePatch = { + message?: string + title?: string | null + level?: string + dismissable?: boolean + announce_at?: string + expires?: string | null + } + + export type PostNoticeResponseBody = { + id: number + } + } + } + + export namespace Actions { + export namespace v1 { + export type SortOrder = 'asc' | 'desc' + + export type ActionName = + | 'server_created' + | 'changed_server_name' + | 'changed_server_subdomain' + | 'server_reallocated' + | 'server_plan_changed' + | 'user_invited' + | 'user_invite_revoked' + | 'user_permission_modified' + | 'user_removed' + | 'addon_added' + | 'addon_uploaded' + | 'addon_disabled' + | 'addon_enabled' + | 'addon_deleted' + | 'addon_updated' + | 'modpack_changed' + | 'modpack_unlinked' + | 'server_repaired' + | 'server_reset' + | 'server_started' + | 'server_stopped' + | 'server_restarted' + | 'server_killed' + | 'port_allocation_added' + | 'port_allocation_removed' + | 'loader_version_edited' + | 'game_version_edited' + | 'server_properties_modified' + | 'file_uploaded' + | 'file_deleted' + | 'file_renamed' + | 'file_edited' + | 'sftp_login' + | 'console_command_executed' + | 'console_cleared' + | 'backup_created' + | 'backup_renamed' + | 'backup_restored' + | 'backup_deleted' + | 'startup_command_modified' + | 'java_runtime_modified' + | 'java_version_modified' + + export type Action = { + action: ActionName | string + metadata?: unknown + } + + export type ActionUser = + | { + type: 'user' + user_id: string + } + | { + type: 'support' + user_id?: string | null + } + + export type ActionEntry = { + actor: ActionUser + action: Action + server_id: string + world_id?: string | null + timestamp: string + } + + export type UserResp = { + username: string + avatar_url: string + } + + export type AddonResp = { + title: string + slug?: string | null + icon_url?: string | null + version?: string | null + } + + export type ActionLogResponse = { + next_offset?: number | null + data: ActionEntry[] + users: Record + addons: Record + } + + export type ActionLogFilter = { + users?: string[] + worlds?: Array + actions?: ActionName[] + } + + export type ListActionLogOptions = { + filter?: ActionLogFilter + limit?: number + offset?: number + order?: SortOrder + } + } + } + + export namespace Transfers { + export namespace Internal { + export type ProvisionOptions = { + region?: string | null + node_tags: string[] + } + + export type ScheduleServerTransfersRequest = { + server_ids: string[] + scheduled_at?: string | null + target_region?: string | null + node_tags?: string[] + reason?: string | null + } + + export type ScheduleNodeTransfersRequest = { + node_hostnames: string[] + scheduled_at?: string | null + target_region?: string | null + node_tags?: string[] + reason?: string | null + cordon_nodes?: boolean + tag_nodes?: string | null + } + + export type ScheduleTransfersResponse = { + batch_id: number + scheduled_count: number + } + + export type CancelTransfersRequest = { + batch_ids: number[] + } + + export type CancelTransfersResponse = { + cancelled_count: number + } + + export type TransferLogBatchEntry = { + id: number + created_by: string + created_at: string + reason?: string | null + scheduled_at: string + cancelled: boolean + log_count: number + provision_options: ProvisionOptions + } + + export type TransferHistoryQuery = { + page?: number + page_size?: number + } + + export type TransferHistoryResponse = { + batches: TransferLogBatchEntry[] + total: number + page: number + page_size: number + } + } + } + export namespace Content { export namespace v1 { export type AddonKind = 'mod' | 'plugin' | 'datapack' | 'shader' | 'resourcepack' @@ -220,6 +488,51 @@ export namespace Archon { } } + export namespace ServerUsers { + export namespace v1 { + export type ServerUserRole = 'Owner' | 'Editor' | 'Viewer' | 'Unknown' + + export type AssignableServerUserRole = Exclude + + export const UserScope = { + NONE: '', + SERVER_ADMIN: 'SERVER_ADMIN', + BASE_READ: 'BASE_READ', + POWER_ACTIONS: 'POWER_ACTIONS', + FILES_WRITE: 'FILES_WRITE', + SETUP: 'SETUP', + BACKUPS: 'BACKUPS', + ADVANCED: 'ADVANCED', + RESET_SERVER: 'RESET_SERVER', + MANAGE_USERS: 'MANAGE_USERS', + SUPPORT_AGENT: 'SUPPORT_AGENT', + INFRA_MANAGER: 'INFRA_MANAGER', + INFRA_MANAGER_READ: 'INFRA_MANAGER_READ', + INFRA_SERVERS_XFER: 'INFRA_SERVERS_XFER', + } as const + + export type UserScope = string + + export type UserResp = { + username: string + avatar_url: string + } + + export type ServerUser = { + user: UserResp + added_on?: string | null + permissions: UserScope + } + + export type AddServerUserRequest = { + server_id?: string | null + user_id: string + added_on?: string | null + role: ServerUserRole + } + } + } + export namespace Servers { export namespace v0 { export type ServerGetResponse = { @@ -279,10 +592,13 @@ export namespace Archon { node: NodeInfo | null flows: Flows is_medal: boolean + current_user_permissions: UserScope medal_expires?: string } + export type UserScope = number + export type Net = { ip: string port: number diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index 7219d12e55..34c710af72 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -1,12 +1,17 @@ import type { AbstractModrinthClient } from '../core/abstract-client' import type { AbstractModule } from '../core/abstract-module' +import { ArchonActionsV1Module } from './archon/actions/v1' import { ArchonBackupsV1Module } from './archon/backups/v1' import { ArchonBackupsQueueV1Module } from './archon/backups-queue/v1' import { ArchonContentV1Module } from './archon/content/v1' +import { ArchonNodesInternalModule } from './archon/nodes/internal' +import { ArchonNoticesV0Module } from './archon/notices/v0' import { ArchonOptionsV1Module } from './archon/options/v1' import { ArchonPropertiesV1Module } from './archon/properties/v1' +import { ArchonServerUsersV1Module } from './archon/server-users/v1' import { ArchonServersV0Module } from './archon/servers/v0' import { ArchonServersV1Module } from './archon/servers/v1' +import { ArchonTransfersInternalModule } from './archon/transfers/internal' import { ISO3166Module } from './iso3166' import { KyrosContentV1Module } from './kyros/content/v1' import { KyrosFilesV0Module } from './kyros/files/v0' @@ -18,6 +23,7 @@ import { LabrinthAuthV2Module } from './labrinth/auth/v2' import { LabrinthBillingInternalModule } from './labrinth/billing/internal' import { LabrinthCollectionsModule } from './labrinth/collections' import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal' +import { LabrinthFriendsV3Module } from './labrinth/friends/v3' import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal' import { LabrinthLimitsV3Module } from './labrinth/limits/v3' import { LabrinthModerationInternalModule } from './labrinth/moderation/internal' @@ -57,13 +63,18 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule * TODO: Better way? Probably not */ export const MODULE_REGISTRY = { + archon_actions_v1: ArchonActionsV1Module, archon_backups_queue_v1: ArchonBackupsQueueV1Module, archon_backups_v1: ArchonBackupsV1Module, archon_content_v1: ArchonContentV1Module, + archon_nodes_internal: ArchonNodesInternalModule, + archon_notices_v0: ArchonNoticesV0Module, archon_options_v1: ArchonOptionsV1Module, archon_properties_v1: ArchonPropertiesV1Module, + archon_server_users_v1: ArchonServerUsersV1Module, archon_servers_v0: ArchonServersV0Module, archon_servers_v1: ArchonServersV1Module, + archon_transfers_internal: ArchonTransfersInternalModule, iso3166_data: ISO3166Module, mclogs_insights_v1: MclogsInsightsV1Module, mclogs_logs_v1: MclogsLogsV1Module, @@ -77,6 +88,7 @@ export const MODULE_REGISTRY = { labrinth_billing_internal: LabrinthBillingInternalModule, labrinth_collections: LabrinthCollectionsModule, labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule, + labrinth_friends_v3: LabrinthFriendsV3Module, labrinth_globals_internal: LabrinthGlobalsInternalModule, labrinth_moderation_internal: LabrinthModerationInternalModule, labrinth_notifications_v2: LabrinthNotificationsV2Module, diff --git a/packages/api-client/src/modules/labrinth/friends/v3.ts b/packages/api-client/src/modules/labrinth/friends/v3.ts new file mode 100644 index 0000000000..d946880dc5 --- /dev/null +++ b/packages/api-client/src/modules/labrinth/friends/v3.ts @@ -0,0 +1,47 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Labrinth } from '../types' + +export class LabrinthFriendsV3Module extends AbstractModule { + public getModuleID(): string { + return 'labrinth_friends_v3' + } + + /** + * Get friends and pending friend requests for the authenticated user + * + * @returns Promise resolving to friend relationships + */ + public async list(): Promise { + return this.client.request('/friends', { + api: 'labrinth', + version: 3, + method: 'GET', + }) + } + + /** + * Send or accept a friend request + * + * @param idOrUsername - The target user's ID or username + */ + public async add(idOrUsername: string): Promise { + return this.client.request(`/friend/${encodeURIComponent(idOrUsername)}`, { + api: 'labrinth', + version: 3, + method: 'POST', + }) + } + + /** + * Remove a friend or pending friend request + * + * @param idOrUsername - The target user's ID or username + */ + public async remove(idOrUsername: string): Promise { + return this.client.request(`/friend/${encodeURIComponent(idOrUsername)}`, { + api: 'labrinth', + version: 3, + method: 'DELETE', + }) + } +} diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts index 883bac31d7..725e57dc95 100644 --- a/packages/api-client/src/modules/labrinth/index.ts +++ b/packages/api-client/src/modules/labrinth/index.ts @@ -3,6 +3,7 @@ export * from './auth/v2' export * from './billing/internal' export * from './collections' export * from './external-projects/internal' +export * from './friends/v3' export * from './globals/internal' export * from './limits/v3' export * from './moderation/internal' diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index aa44c19228..5cf4e800b8 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -1059,6 +1059,17 @@ export namespace Labrinth { } } + export namespace Friends { + export namespace v3 { + export type UserFriend = { + id: string + friend_id: string + accepted: boolean + created: string + } + } + } + export namespace ServerPing { export namespace Internal { export type MinecraftJavaPingRequest = { diff --git a/packages/api-client/src/platform/xhr-upload-client.ts b/packages/api-client/src/platform/xhr-upload-client.ts index d16e765899..40190f96d4 100644 --- a/packages/api-client/src/platform/xhr-upload-client.ts +++ b/packages/api-client/src/platform/xhr-upload-client.ts @@ -18,9 +18,9 @@ export abstract class XHRUploadClient extends AbstractModrinthClient { upload(path: string, options: UploadRequestOptions): UploadHandle { let baseUrl: string if (options.api === 'labrinth') { - baseUrl = this.config.labrinthBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!) } else if (options.api === 'archon') { - baseUrl = this.config.archonBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!) } else { baseUrl = options.api } diff --git a/packages/api-client/src/types/client.ts b/packages/api-client/src/types/client.ts index 45828d5c20..30ea76b259 100644 --- a/packages/api-client/src/types/client.ts +++ b/packages/api-client/src/types/client.ts @@ -1,6 +1,7 @@ import type { AbstractFeature } from '../core/abstract-feature' import type { RequestContext } from './request' +export type BaseUrlConfig = string | (() => string) export type MaybePromise = T | Promise export type UserAgentProvider = string | (() => MaybePromise) @@ -39,13 +40,15 @@ export interface ClientConfig { * Base URL for Labrinth API (main Modrinth API) * @default 'https://api.modrinth.com' */ - labrinthBaseUrl?: string + labrinthBaseUrl?: BaseUrlConfig /** * Base URL for Archon API (Modrinth Hosting API) + * Can be a callback so apps can drive this from runtime feature flags. + * * @default 'https://archon.modrinth.com' */ - archonBaseUrl?: string + archonBaseUrl?: BaseUrlConfig /** * Default request timeout in milliseconds diff --git a/packages/api-client/src/types/index.ts b/packages/api-client/src/types/index.ts index 30daf6520c..2fb40b14e4 100644 --- a/packages/api-client/src/types/index.ts +++ b/packages/api-client/src/types/index.ts @@ -7,7 +7,7 @@ export type { } 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 { BaseUrlConfig, ClientConfig, RequestHooks } from './client' export type { ApiErrorData, ModrinthErrorResponse } from './errors' export { isModrinthErrorResponse } from './errors' export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request' diff --git a/packages/assets/external/illustrations/intercom_bubble_icon.png b/packages/assets/external/illustrations/intercom_bubble_icon.png new file mode 100644 index 0000000000..6585b9b09e Binary files /dev/null and b/packages/assets/external/illustrations/intercom_bubble_icon.png differ diff --git a/packages/assets/index.ts b/packages/assets/index.ts index 4fb4028886..7a8ed170cd 100644 --- a/packages/assets/index.ts +++ b/packages/assets/index.ts @@ -41,6 +41,7 @@ import _DiscordIcon from './external/discord.svg?component' import _FacebookIcon from './external/facebook.svg?component' import _FlathubIcon from './external/flathub.svg?component' import _GithubIcon from './external/github.svg?component' +import _IntercomBubbleIcon from './external/illustrations/intercom_bubble_icon.png?url' import _MinecraftServerIcon from './external/illustrations/minecraft_server_icon.png?url' import _InstagramIcon from './external/instagram.svg?component' import _KoFiIcon from './external/kofi.svg?component' @@ -132,6 +133,7 @@ export const VenmoIcon = _VenmoIcon export const PolygonIcon = _PolygonIcon export const USDCColorIcon = _USDCColorIcon export const VisaIcon = _VisaIcon +export const IntercomBubbleIcon = _IntercomBubbleIcon export const MinecraftServerIcon = _MinecraftServerIcon export * from './generated-icons' diff --git a/packages/ui/.storybook/preview.scss b/packages/ui/.storybook/preview.scss new file mode 100644 index 0000000000..b9e6de3ad7 --- /dev/null +++ b/packages/ui/.storybook/preview.scss @@ -0,0 +1,17 @@ +html { + min-height: 100%; + overflow: auto; +} + +body { + position: static !important; + width: auto !important; + min-height: 100vh; + height: auto !important; + overflow: auto !important; +} + +#storybook-root { + min-height: 100vh; + height: auto; +} diff --git a/packages/ui/.storybook/preview.ts b/packages/ui/.storybook/preview.ts index 9b340008eb..e68f912e93 100644 --- a/packages/ui/.storybook/preview.ts +++ b/packages/ui/.storybook/preview.ts @@ -6,6 +6,7 @@ import '../../assets/styles/defaults.scss' // --- // app-frontend css imports import '../../../apps/app-frontend/src/assets/stylesheets/global.scss' +import './preview.scss' import type { Labrinth } from '@modrinth/api-client' import { GenericModrinthClient } from '@modrinth/api-client' diff --git a/packages/ui/src/components/base/BaseTerminal.vue b/packages/ui/src/components/base/BaseTerminal.vue index d266fd07ee..bb6c1ddf9a 100644 --- a/packages/ui/src/components/base/BaseTerminal.vue +++ b/packages/ui/src/components/base/BaseTerminal.vue @@ -26,8 +26,9 @@ > { + if (props.disableInput) return const cmd = commandInput.value.trim() if (!cmd) return emit('command', cmd) diff --git a/packages/ui/src/components/base/Combobox.vue b/packages/ui/src/components/base/Combobox.vue index bf340ffd58..c2b65ff568 100644 --- a/packages/ui/src/components/base/Combobox.vue +++ b/packages/ui/src/components/base/Combobox.vue @@ -95,6 +95,7 @@ class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 border border-solid border-surface-5" :class="[ openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl', + props.dropdownClass, ]" :style="dropdownStyle" :role="listbox ? 'listbox' : 'menu'" @@ -157,7 +158,7 @@ -
+
{{ noOptionsMessage }}
@@ -229,6 +230,9 @@ const props = withDefaults( forceDirection?: 'up' | 'down' noOptionsMessage?: string disableSearchFilter?: boolean + dropdownClass?: string + dropdownMinWidth?: string + minSearchLengthToOpen?: number /** Keep the selected option's label in the input after selection, and show all options on focus */ syncWithSelection?: boolean /** Show a search icon in the searchable input */ @@ -244,6 +248,7 @@ const props = withDefaults( showIconInSelected: false, maxHeight: DEFAULT_MAX_HEIGHT, noOptionsMessage: 'No results found', + minSearchLengthToOpen: 0, syncWithSelection: true, showSearchIcon: false, }, @@ -283,6 +288,7 @@ const dropdownStyle = ref({ top: '0px', left: '0px', width: '0px', + minWidth: '0px', }) const openDirection = ref<'down' | 'up'>('down') @@ -316,6 +322,10 @@ const triggerText = computed(() => { return props.placeholder }) +const hasMinimumSearchLength = computed( + () => !props.searchable || searchQuery.value.trim().length >= props.minSearchLengthToOpen, +) + const optionsWithKeys = computed(() => { return props.options.map((opt, index) => ({ ...opt, @@ -426,6 +436,7 @@ async function updateDropdownPosition() { top: `${top}px`, left: `${left}px`, width: `${triggerRect.width}px`, + minWidth: props.dropdownMinWidth ?? `${triggerRect.width}px`, } openDirection.value = direction @@ -433,6 +444,7 @@ async function updateDropdownPosition() { async function openDropdown() { if (props.disabled || isOpen.value) return + if (!hasMinimumSearchLength.value) return isOpen.value = true emit('open') @@ -622,6 +634,10 @@ function handleSearchKeydown(event: KeyboardEvent) { function handleSearchInput() { userHasTyped.value = true emit('searchInput', searchQuery.value) + if (!hasMinimumSearchLength.value) { + closeDropdown() + return + } if (!isOpen.value) { openDropdown() } @@ -689,10 +705,16 @@ watch(filteredOptions, () => { } }) +watch(hasMinimumSearchLength, (canOpen) => { + if (!canOpen) { + closeDropdown() + } +}) + watch( [() => props.modelValue, () => props.options], ([val]) => { - if (props.searchable && props.syncWithSelection && !isOpen.value) { + if (props.searchable && props.syncWithSelection && !isOpen.value && !userHasTyped.value) { const opt = props.options.find((o) => isDropdownOption(o) && o.value === val) searchQuery.value = opt && isDropdownOption(opt) ? opt.label : '' } diff --git a/packages/ui/src/components/base/JoinedButtons.vue b/packages/ui/src/components/base/JoinedButtons.vue index 2c56d50414..8aab2dc96d 100644 --- a/packages/ui/src/components/base/JoinedButtons.vue +++ b/packages/ui/src/components/base/JoinedButtons.vue @@ -2,6 +2,7 @@