feat: content tab rewrite for worlds (#5136)
* feat: base content card component * fix: tooltips + colors * feat: fix orgs * feat: base content tab internals rewrite * feat: fix invalidmodal * feat: add ContentModpackCard * fix: extract types * draft: layout * feat: unlink modal * feat: impl content tab * fix: lint * fix: toggling * temp: disable updating stuff * feat: selection v-model * feat: bulk selection * feat: mods tab rough draft * feat: use fuse.js * feat: add project combobox * clean up project combobox * feat: start install to play modal * fix: events * feat: use v-on * feat: bulk actions + fix floating action bar width * feat: figma alignments * feat: migrate toggle to tailwind * fix: row borders * feat: disabled state * feat: virtual list impl for card table based on window scroll * fix: lint * feat: virtualization + smaller contentcard items * feat: use ContentCardTable + ContentCardItems * feat: fix gap + border issues on last elm * feat: cleanup + use proper searching * fix: use TeleportOverflowMenu * fix: fallback to svg if src is invalid on avatar component * fix: storybook * feat: start on updater modal * feat: finish content updater modal * feat: i18n pass * feat: impl modal * feat(app): backend changes for content tab refactor (#5237) * feat: include_changelog=false for updater modal * fix: hash overrides * feat: update checking for modpack * feat: qa * feat: modpack content modal * fix: padding in table to match modals + tightness * fix: lint * feat: delete modal * feat: fix toggle bugs * fix: prepr * fix: duplicate messages * qa: full width search * qa: use bg-surface-1.5 * qa: animation for filter pills * qa: standardize hover colors * fix: border-[1px] is border * qa: mass de-select actually mass selecting * qa: match figma designs for floating action bar * qa: modal fixes * q: modal fixes x2 * fix: table border * qa: confirm modals * qa: modal alignment * qa: re-add stuck heading + dedupe logic * qa: dedupe virtual scrolling + remove dead components * qa: responsiveness for content table + link fixes * qa: version column link, tooltips + lint fixes * qa: instance busy protections * fix: installation freeze bug * chore: remove old mods page * refactor: deduplicate layout * chore: delete old content page(s) * qa * qa * qa * feat: sort btn - to iterate * fix: ml * feat: date added * fix: lint * fix: formatting.ts removal * feat: get_dependencies_as_content_items * qa: final QA changes * refactor: deduplicate + polish content.rs * feat: hook up content.vue with v1 * feat: hide v1 content api behind frontend feature flag * fix: query keys + copy on empty state * chore: i18n pass * feat: reimpl unlink + upload endpoint * feat: use bulk endpoints v1 * fix: lint * fix: flags * fix: responsiveness via container queries * fix: lint * qa: 1 * qa: fixes * qa: fix ssr issues with browse content * qa: header page divider * qa: modals * fix: prepr * fix: issues * fix: lint * fix: toggle v1 ff * qa: 5 * qa: delete modal copy * feat: creation flow modals (#5383) * refactor: delete content v0 usages + impl * feat: qa + fixes * feat: installing banner using state event * feat: fix modpack card bugs + filtering issues * refactor: delete backups v0 api module * feat: v1 servers GET endpoint * fix: backups * feat: swap to kyros upload v1 addon * fix: use tanstack for loader.vue * feat: finish install from discovery modal * qa: bug fixes * feat: set up installation settings * fix: lint * fix: typos * fix: bugs * fix: disable inline content * feat: content tab improvements — upload UX, installation settings, and client-only indicators Upload cancellation and navigation guard: - Add ConfirmLeaveModal that prompts when navigating away during upload - Cancel in-flight XHR uploads when user confirms leaving the page - Add beforeunload handler to warn on browser/tab close during upload - Track uploadedBytes/totalBytes in UploadState for progress display - Replace Collapsible with Transition for upload progress admonition - Show byte progress and percentage in upload banner - Clamp upload progress to prevent exceeding 100% Installation settings (server.properties): - Add KnownPropertiesFields and PropertiesFields types to Archon types - Add buildProperties() to creation flow context to collect gamemode, difficulty, seed, world type, structures, and generator settings - Pass properties through installContent on onboarding, discovery, and ServerSetupModal flows Server setup and discovery flow improvements: - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent - Replace loaderApiNames lookup with toApiLoader() helper - Remove eraseDataOnInstall toggle — always use soft_override: false - Simplify modpack install on discovery page to use first available version and route through creation flow modal for both onboarding and non-onboarding - Differentiate post-install navigation: content page for onboarding, loader options for existing servers Modpack update flow: - Replace updateModpack() call with installContent() using soft_override: true to support version selection in the content updater modal Client-only mod indicators: - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment) - Add environment to ContentItem and isClientOnly to ContentCardTableItem - Show orange TriangleAlertIcon with tooltip on client-only mods in content table - Add "Client-only" filter pill to content filtering (controlled via showClientOnlyFilter on ContentManagerContext) - Apply client-only indicators in both ContentPageLayout and ModpackContentModal Misc: - Add CLAUDE.md note about using prepr commands for lint checks - Export ConfirmLeaveModal from instances barrel * fix: piping * fix: switch content disable for linked server instances * feat: client only filter * fix: prepr * feat: hasUpdate shape update * feat: bulk update endpoint impl for content in panel * feat: websocket state impl again with new phases * fix: ws * fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug * fix: qa bugs * fix: lint, a11y and i18n * refactor: set up layouts folder properly * fix: linked data cache stuff + lint * feat: move installationsettings to shared layout * fix: lint * fix: issues * feat: temp fuck staging up * fix: lockfile * fix: data sync issues on loader.vue * fix: lint * Hide shader configuration files from content list (#5499) * feat: workaround search problem + split out reset * fix: qa * fix: changelog not showing on first open * fix: qa + optimistic updating improvements * fix: prepr+lint * fix: qa * feat: qa * fix: lint * fix: lint * fix: build * fix: build * fix: type errors * fix: fade and JAVA_HOME passthrough * feat: qa * feat: impl diff shit * fix: qa * fix: app qa * feat: update diff modal * fix: endpoint * fix: qa * fix: qa * fix: use bulk in modpack modal * feat: abort signal impl + fix issues * fix: diff modal trunc * feat: qa * fix: qa * feat: tooltip content tab * fix: prepr * fix: dismiss on settings btn * feat: qa * feat: dont clear handlers on disconnect * fix: lint * fix: wrangler + introduce staging-archon env file --------- Signed-off-by: Calum H. <calum@modrinth.com> Co-authored-by: tdgao <mr.trumgao@gmail.com> Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
This commit is contained in:
@@ -43,6 +43,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
hidePreviewBanner: false,
|
||||
i18nDebug: false,
|
||||
showDiscoverProjectButtons: false,
|
||||
useV1ContentTabAPI: true,
|
||||
labrinthApiCanary: false,
|
||||
} as const)
|
||||
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import type { AbstractWebNotificationManager } from '@modrinth/ui'
|
||||
import type { JWTAuth, ModuleError, ModuleName } from '@modrinth/utils'
|
||||
import { ModrinthServerError } from '@modrinth/utils'
|
||||
|
||||
import { ContentModule, GeneralModule, NetworkModule, StartupModule } from './modules/index.ts'
|
||||
import { useServersFetch } from './servers-fetch.ts'
|
||||
|
||||
export function handleServersError(err: any, notifications: AbstractWebNotificationManager) {
|
||||
if (err instanceof ModrinthServerError && err.v1Error) {
|
||||
notifications.addNotification({
|
||||
title: err.v1Error?.context ?? `An error occurred`,
|
||||
type: 'error',
|
||||
text: err.v1Error.description,
|
||||
errorCode: err.v1Error.error,
|
||||
})
|
||||
} else {
|
||||
notifications.addNotification({
|
||||
title: 'An error occurred',
|
||||
type: 'error',
|
||||
text: err.message ?? (err.data ? err.data.description : err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class ModrinthServer {
|
||||
readonly serverId: string
|
||||
private errors: Partial<Record<ModuleName, ModuleError>> = {}
|
||||
|
||||
readonly general: GeneralModule
|
||||
readonly content: ContentModule
|
||||
readonly network: NetworkModule
|
||||
readonly startup: StartupModule
|
||||
|
||||
constructor(serverId: string) {
|
||||
this.serverId = serverId
|
||||
|
||||
this.general = new GeneralModule(this)
|
||||
this.content = new ContentModule(this)
|
||||
this.network = new NetworkModule(this)
|
||||
this.startup = new StartupModule(this)
|
||||
}
|
||||
|
||||
async fetchConfigFile(fileName: string): Promise<any> {
|
||||
return await useServersFetch(`servers/${this.serverId}/config/${fileName}`)
|
||||
}
|
||||
|
||||
constructServerProperties(properties: any): string {
|
||||
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`
|
||||
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
if (typeof value === 'object') {
|
||||
fileContent += `${key}=${JSON.stringify(value)}\n`
|
||||
} else if (typeof value === 'boolean') {
|
||||
fileContent += `${key}=${value ? 'true' : 'false'}\n`
|
||||
} else {
|
||||
fileContent += `${key}=${value}\n`
|
||||
}
|
||||
}
|
||||
|
||||
return fileContent
|
||||
}
|
||||
|
||||
async processImage(iconUrl: string | undefined): Promise<string | undefined> {
|
||||
const sharedImage = useState<string | undefined>(`server-icon-${this.serverId}`)
|
||||
|
||||
if (sharedImage.value) {
|
||||
return sharedImage.value
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`)
|
||||
try {
|
||||
const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
|
||||
override: auth,
|
||||
retry: 1, // Reduce retries for optional resources
|
||||
})
|
||||
|
||||
if (fileData instanceof Blob && import.meta.client) {
|
||||
const dataURL = await new Promise<string>((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
canvas.width = 512
|
||||
canvas.height = 512
|
||||
ctx?.drawImage(img, 0, 0, 512, 512)
|
||||
const dataURL = canvas.toDataURL('image/png')
|
||||
sharedImage.value = dataURL
|
||||
resolve(dataURL)
|
||||
URL.revokeObjectURL(img.src)
|
||||
}
|
||||
img.src = URL.createObjectURL(fileData)
|
||||
})
|
||||
return dataURL
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServerError) {
|
||||
if (error.statusCode && error.statusCode >= 500) {
|
||||
console.debug('Service unavailable, skipping icon processing')
|
||||
sharedImage.value = undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (error.statusCode === 404 && iconUrl) {
|
||||
try {
|
||||
const response = await fetch(iconUrl)
|
||||
if (!response.ok) throw new Error('Failed to fetch icon')
|
||||
const file = await response.blob()
|
||||
const originalFile = new File([file], 'server-icon-original.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
|
||||
if (import.meta.client) {
|
||||
const dataURL = await new Promise<string>((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
canvas.width = 64
|
||||
canvas.height = 64
|
||||
ctx?.drawImage(img, 0, 0, 64, 64)
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (blob) {
|
||||
const scaledFile = new File([blob], 'server-icon.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
await useServersFetch(`/create?path=/server-icon.png&type=file`, {
|
||||
method: 'POST',
|
||||
contentType: 'application/octet-stream',
|
||||
body: scaledFile,
|
||||
override: auth,
|
||||
})
|
||||
await useServersFetch(`/create?path=/server-icon-original.png&type=file`, {
|
||||
method: 'POST',
|
||||
contentType: 'application/octet-stream',
|
||||
body: originalFile,
|
||||
override: auth,
|
||||
})
|
||||
}
|
||||
}, 'image/png')
|
||||
const dataURL = canvas.toDataURL('image/png')
|
||||
sharedImage.value = dataURL
|
||||
resolve(dataURL)
|
||||
URL.revokeObjectURL(img.src)
|
||||
}
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
return dataURL
|
||||
}
|
||||
} catch (externalError: any) {
|
||||
console.debug('Could not process external icon:', externalError.message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.debug('Icon processing failed:', error.message)
|
||||
}
|
||||
|
||||
sharedImage.value = undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
async testNodeReachability(): Promise<boolean> {
|
||||
if (!this.general?.node?.instance) {
|
||||
console.warn('No node instance available for ping test')
|
||||
return false
|
||||
}
|
||||
|
||||
const wsUrl = `wss://${this.general.node.instance}/pingtest`
|
||||
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
const socket = new WebSocket(wsUrl)
|
||||
const timeout = setTimeout(() => {
|
||||
socket.close()
|
||||
resolve(false)
|
||||
}, 5000)
|
||||
|
||||
socket.onopen = () => {
|
||||
clearTimeout(timeout)
|
||||
socket.send(performance.now().toString())
|
||||
}
|
||||
|
||||
socket.onmessage = () => {
|
||||
clearTimeout(timeout)
|
||||
socket.close()
|
||||
resolve(true)
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to ping node ${wsUrl}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(
|
||||
modules: ModuleName[] = [],
|
||||
options?: {
|
||||
preserveConnection?: boolean
|
||||
preserveInstallState?: boolean
|
||||
},
|
||||
): Promise<void> {
|
||||
const modulesToRefresh =
|
||||
modules.length > 0 ? modules : (['general', 'content', 'network', 'startup'] as ModuleName[])
|
||||
|
||||
for (const module of modulesToRefresh) {
|
||||
this.errors[module] = undefined
|
||||
|
||||
try {
|
||||
switch (module) {
|
||||
case 'general': {
|
||||
if (options?.preserveConnection) {
|
||||
const currentImage = this.general.image
|
||||
const currentMotd = this.general.motd
|
||||
const currentStatus = this.general.status
|
||||
|
||||
await this.general.fetch()
|
||||
|
||||
if (currentImage) {
|
||||
this.general.image = currentImage
|
||||
}
|
||||
if (currentMotd) {
|
||||
this.general.motd = currentMotd
|
||||
}
|
||||
if (options.preserveInstallState && currentStatus === 'installing') {
|
||||
this.general.status = 'installing'
|
||||
}
|
||||
} else {
|
||||
await this.general.fetch()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'content':
|
||||
await this.content.fetch()
|
||||
break
|
||||
case 'network':
|
||||
await this.network.fetch()
|
||||
break
|
||||
case 'startup':
|
||||
await this.startup.fetch()
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServerError) {
|
||||
if (error.statusCode === 404 && module === 'content') {
|
||||
console.debug(`Optional ${module} resource not found:`, error.message)
|
||||
continue
|
||||
}
|
||||
|
||||
if (error.statusCode && error.statusCode >= 500) {
|
||||
console.debug(`Temporary ${module} unavailable:`, error.message)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
this.errors[module] = {
|
||||
error:
|
||||
error instanceof ModrinthServerError
|
||||
? error
|
||||
: new ModrinthServerError('Unknown error', undefined, error as Error),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get moduleErrors() {
|
||||
return this.errors
|
||||
}
|
||||
}
|
||||
|
||||
export const useModrinthServers = async (
|
||||
serverId: string,
|
||||
includedModules: ModuleName[] = ['general'],
|
||||
) => {
|
||||
const server = new ModrinthServer(serverId)
|
||||
await server.refresh(includedModules)
|
||||
return reactive(server)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import type { AutoBackupSettings, Backup } from '@modrinth/utils'
|
||||
|
||||
import { useServersFetch } from '../servers-fetch.ts'
|
||||
import { ServerModule } from './base.ts'
|
||||
|
||||
export class BackupsModule extends ServerModule {
|
||||
data: Backup[] = []
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
this.data = await useServersFetch<Backup[]>(`servers/${this.serverId}/backups`, {}, 'backups')
|
||||
}
|
||||
|
||||
async create(backupName: string): Promise<string> {
|
||||
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substring(7)}`
|
||||
const tempBackup: Backup = {
|
||||
id: tempId,
|
||||
name: backupName,
|
||||
created_at: new Date().toISOString(),
|
||||
locked: false,
|
||||
automated: false,
|
||||
interrupted: false,
|
||||
ongoing: true,
|
||||
task: { create: { progress: 0, state: 'ongoing' } },
|
||||
}
|
||||
this.data.push(tempBackup)
|
||||
|
||||
try {
|
||||
const response = await useServersFetch<{ id: string }>(`servers/${this.serverId}/backups`, {
|
||||
method: 'POST',
|
||||
body: { name: backupName },
|
||||
})
|
||||
|
||||
const backup = this.data.find((b) => b.id === tempId)
|
||||
if (backup) {
|
||||
backup.id = response.id
|
||||
}
|
||||
|
||||
return response.id
|
||||
} catch (error) {
|
||||
this.data = this.data.filter((b) => b.id !== tempId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async rename(backupId: string, newName: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/rename`, {
|
||||
method: 'POST',
|
||||
body: { name: newName },
|
||||
})
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async delete(backupId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async restore(backupId: string): Promise<void> {
|
||||
const backup = this.data.find((b) => b.id === backupId)
|
||||
if (backup) {
|
||||
if (!backup.task) backup.task = {}
|
||||
backup.task.restore = { progress: 0, state: 'ongoing' }
|
||||
}
|
||||
|
||||
try {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/restore`, {
|
||||
method: 'POST',
|
||||
})
|
||||
} catch (error) {
|
||||
if (backup?.task?.restore) {
|
||||
delete backup.task.restore
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async lock(backupId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/lock`, {
|
||||
method: 'POST',
|
||||
})
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async unlock(backupId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/unlock`, {
|
||||
method: 'POST',
|
||||
})
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async retry(backupId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/retry`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async updateAutoBackup(autoBackup: 'enable' | 'disable', interval: number): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/autobackup`, {
|
||||
method: 'POST',
|
||||
body: { set: autoBackup, interval },
|
||||
})
|
||||
}
|
||||
|
||||
async getAutoBackup(): Promise<AutoBackupSettings> {
|
||||
return await useServersFetch(`servers/${this.serverId}/autobackup`)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ModrinthServer } from '../modrinth-servers.ts'
|
||||
|
||||
export abstract class ServerModule {
|
||||
protected server: ModrinthServer
|
||||
|
||||
constructor(server: ModrinthServer) {
|
||||
this.server = server
|
||||
}
|
||||
|
||||
protected get serverId(): string {
|
||||
return this.server.serverId
|
||||
}
|
||||
|
||||
abstract fetch(): Promise<void>
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { ContentType, Mod } from '@modrinth/utils'
|
||||
|
||||
import { useServersFetch } from '../servers-fetch.ts'
|
||||
import { ServerModule } from './base.ts'
|
||||
|
||||
export class ContentModule extends ServerModule {
|
||||
data: Mod[] = []
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
const mods = await useServersFetch<Mod[]>(`servers/${this.serverId}/mods`, {}, 'content')
|
||||
this.data = mods.sort((a, b) => (a?.name ?? '').localeCompare(b?.name ?? ''))
|
||||
}
|
||||
|
||||
async install(contentType: ContentType, projectId: string, versionId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/mods`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
rinth_ids: { project_id: projectId, version_id: versionId },
|
||||
install_as: contentType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async remove(path: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/deleteMod`, {
|
||||
method: 'POST',
|
||||
body: { path },
|
||||
})
|
||||
}
|
||||
|
||||
async reinstall(replace: string, projectId: string, versionId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/mods/update`, {
|
||||
method: 'POST',
|
||||
body: { replace, project_id: projectId, version_id: versionId },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import type { JWTAuth, PowerAction, Project, ServerGeneral } from '@modrinth/utils'
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
import { useServersFetch } from '../servers-fetch.ts'
|
||||
import { ServerModule } from './base.ts'
|
||||
|
||||
export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||
server_id!: string
|
||||
name!: string
|
||||
owner_id!: string
|
||||
net!: { ip: string; port: number; domain: string }
|
||||
game!: string
|
||||
backup_quota!: number
|
||||
used_backup_quota!: number
|
||||
status!: string
|
||||
suspension_reason!: string
|
||||
loader!: string
|
||||
loader_version!: string
|
||||
mc_version!: string
|
||||
upstream!: {
|
||||
kind: 'modpack' | 'mod' | 'resourcepack'
|
||||
version_id: string
|
||||
project_id: string
|
||||
} | null
|
||||
|
||||
motd?: string
|
||||
image?: string
|
||||
project?: Project
|
||||
sftp_username!: string
|
||||
sftp_password!: string
|
||||
sftp_host!: string
|
||||
datacenter?: string
|
||||
notices?: any[]
|
||||
node!: { token: string; instance: string }
|
||||
flows?: { intro?: boolean }
|
||||
|
||||
is_medal?: boolean
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
const data = await useServersFetch<ServerGeneral>(`servers/${this.serverId}`, {}, 'general')
|
||||
|
||||
if (data.upstream?.project_id) {
|
||||
const project = await $fetch(
|
||||
`https://api.modrinth.com/v2/project/${data.upstream.project_id}`,
|
||||
)
|
||||
data.project = project as Project
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined
|
||||
}
|
||||
|
||||
// Copy data to this module
|
||||
Object.assign(this, data)
|
||||
}
|
||||
|
||||
async updateName(newName: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/name`, {
|
||||
method: 'POST',
|
||||
body: { name: newName },
|
||||
})
|
||||
}
|
||||
|
||||
async power(action: PowerAction): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/power`, {
|
||||
method: 'POST',
|
||||
body: { action },
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
await this.fetch() // Refresh this module
|
||||
}
|
||||
|
||||
async reinstall(
|
||||
loader: boolean,
|
||||
projectId: string,
|
||||
versionId?: string,
|
||||
loaderVersionId?: string,
|
||||
hardReset: boolean = false,
|
||||
): Promise<void> {
|
||||
const hardResetParam = hardReset ? 'true' : 'false'
|
||||
if (loader) {
|
||||
if (projectId.toLowerCase() === 'neoforge') {
|
||||
projectId = 'NeoForge'
|
||||
}
|
||||
await useServersFetch(`servers/${this.serverId}/reinstall?hard=${hardResetParam}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
loader: projectId,
|
||||
loader_version: loaderVersionId,
|
||||
game_version: versionId,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await useServersFetch(`servers/${this.serverId}/reinstall?hard=${hardResetParam}`, {
|
||||
method: 'POST',
|
||||
body: { project_id: projectId, version_id: versionId },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
reinstallFromMrpack(
|
||||
mrpack: File,
|
||||
hardReset: boolean = false,
|
||||
): {
|
||||
promise: Promise<void>
|
||||
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void
|
||||
} {
|
||||
const hardResetParam = hardReset ? 'true' : 'false'
|
||||
|
||||
const progressSubject = new EventTarget()
|
||||
|
||||
const uploadPromise = (async () => {
|
||||
try {
|
||||
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
progressSubject.dispatchEvent(
|
||||
new CustomEvent('progress', {
|
||||
detail: {
|
||||
loaded: e.loaded,
|
||||
total: e.total,
|
||||
progress: (e.loaded / e.total) * 100,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.onload = () =>
|
||||
xhr.status >= 200 && xhr.status < 300
|
||||
? resolve()
|
||||
: reject(new Error(`[pyroservers] XHR error status: ${xhr.status}`))
|
||||
|
||||
xhr.onerror = () => reject(new Error('[pyroservers] .mrpack upload failed'))
|
||||
xhr.onabort = () => reject(new Error('[pyroservers] .mrpack upload cancelled'))
|
||||
xhr.ontimeout = () => reject(new Error('[pyroservers] .mrpack upload timed out'))
|
||||
xhr.timeout = 30 * 60 * 1000
|
||||
|
||||
xhr.open('POST', `https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${auth.token}`)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', mrpack)
|
||||
xhr.send(formData)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error reinstalling from mrpack:', err)
|
||||
throw err
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
promise: uploadPromise,
|
||||
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) =>
|
||||
progressSubject.addEventListener('progress', ((e: CustomEvent) =>
|
||||
cb(e.detail)) as EventListener),
|
||||
}
|
||||
}
|
||||
|
||||
async suspend(status: boolean): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/suspend`, {
|
||||
method: 'POST',
|
||||
body: { suspended: status },
|
||||
})
|
||||
}
|
||||
|
||||
async endIntro(): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/flows/intro`, {
|
||||
method: 'DELETE',
|
||||
version: 1,
|
||||
})
|
||||
await this.fetch() // Refresh this module
|
||||
}
|
||||
|
||||
async setMotd(motd: string): Promise<void> {
|
||||
try {
|
||||
const props = (await this.server.fetchConfigFile('ServerProperties')) as any
|
||||
if (props) {
|
||||
props.motd = motd
|
||||
const newProps = this.server.constructServerProperties(props)
|
||||
const octetStream = new Blob([newProps], { type: 'application/octet-stream' })
|
||||
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`)
|
||||
|
||||
await useServersFetch(`/update?path=/server.properties`, {
|
||||
method: 'PUT',
|
||||
contentType: 'application/octet-stream',
|
||||
body: octetStream,
|
||||
override: auth,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
console.error(
|
||||
'[Modrinth Hosting] [General] Failed to set MOTD due to lack of server properties file.',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export * from './backups.ts'
|
||||
export * from './base.ts'
|
||||
export * from './content.ts'
|
||||
export * from './general.ts'
|
||||
export * from './network.ts'
|
||||
export * from './startup.ts'
|
||||
export * from './ws.ts'
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Allocation } from '@modrinth/utils'
|
||||
|
||||
import { useServersFetch } from '../servers-fetch.ts'
|
||||
import { ServerModule } from './base.ts'
|
||||
|
||||
export class NetworkModule extends ServerModule {
|
||||
allocations: Allocation[] = []
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
this.allocations = await useServersFetch<Allocation[]>(
|
||||
`servers/${this.serverId}/allocations`,
|
||||
{},
|
||||
'network',
|
||||
)
|
||||
}
|
||||
|
||||
async reserveAllocation(name: string): Promise<Allocation> {
|
||||
return await useServersFetch<Allocation>(`servers/${this.serverId}/allocations?name=${name}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async updateAllocation(port: number, name: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/allocations/${port}?name=${name}`, {
|
||||
method: 'PUT',
|
||||
})
|
||||
}
|
||||
|
||||
async deleteAllocation(port: number): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/allocations/${port}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
async checkSubdomainAvailability(subdomain: string): Promise<boolean> {
|
||||
const result = (await useServersFetch(`subdomains/${subdomain}/isavailable`)) as {
|
||||
available: boolean
|
||||
}
|
||||
return result.available
|
||||
}
|
||||
|
||||
async changeSubdomain(subdomain: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/subdomain`, {
|
||||
method: 'POST',
|
||||
body: { subdomain },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { JDKBuild, JDKVersion, Startup } from '@modrinth/utils'
|
||||
|
||||
import { useServersFetch } from '../servers-fetch.ts'
|
||||
import { ServerModule } from './base.ts'
|
||||
|
||||
export class StartupModule extends ServerModule implements Startup {
|
||||
invocation!: string
|
||||
original_invocation!: string
|
||||
jdk_version!: JDKVersion
|
||||
jdk_build!: JDKBuild
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
const data = await useServersFetch<Startup>(`servers/${this.serverId}/startup`, {}, 'startup')
|
||||
Object.assign(this, data)
|
||||
}
|
||||
|
||||
async update(invocation: string, jdkVersion: JDKVersion, jdkBuild: JDKBuild): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/startup`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
invocation: invocation || null,
|
||||
jdk_version: jdkVersion || null,
|
||||
jdk_build: jdkBuild || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { JWTAuth } from '@modrinth/utils'
|
||||
|
||||
import { useServersFetch } from '../servers-fetch.ts'
|
||||
import { ServerModule } from './base.ts'
|
||||
|
||||
export class WSModule extends ServerModule implements JWTAuth {
|
||||
url!: string
|
||||
token!: string
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
const data = await useServersFetch<JWTAuth>(`servers/${this.serverId}/ws`, {}, 'ws')
|
||||
Object.assign(this, data)
|
||||
}
|
||||
}
|
||||
131
apps/frontend/src/composables/servers/use-server-image.ts
Normal file
131
apps/frontend/src/composables/servers/use-server-image.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { injectModrinthClient } from '@modrinth/ui'
|
||||
import { type ComputedRef, ref, watch } from 'vue'
|
||||
|
||||
// TODO: Remove and use V1 when available
|
||||
export function useServerImage(
|
||||
serverId: string,
|
||||
upstream: ComputedRef<Archon.Servers.v0.Server['upstream'] | null>,
|
||||
) {
|
||||
const client = injectModrinthClient()
|
||||
const image = ref<string | undefined>()
|
||||
|
||||
const sharedImage = useState<string | undefined>(`server-icon-${serverId}`)
|
||||
if (sharedImage.value) {
|
||||
image.value = sharedImage.value
|
||||
}
|
||||
|
||||
async function loadImage() {
|
||||
if (sharedImage.value) {
|
||||
image.value = sharedImage.value
|
||||
return
|
||||
}
|
||||
|
||||
if (import.meta.server) return
|
||||
|
||||
const cached = localStorage.getItem(`server-icon-${serverId}`)
|
||||
if (cached) {
|
||||
sharedImage.value = cached
|
||||
image.value = cached
|
||||
return
|
||||
}
|
||||
|
||||
let projectIconUrl: string | undefined
|
||||
const upstreamVal = upstream.value
|
||||
if (upstreamVal?.project_id) {
|
||||
try {
|
||||
const project = await $fetch<{ icon_url?: string }>(
|
||||
`https://api.modrinth.com/v2/project/${upstreamVal.project_id}`,
|
||||
)
|
||||
projectIconUrl = project.icon_url
|
||||
} catch {
|
||||
// project fetch failed, continue without icon url
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fileData = await client.kyros.files_v0.downloadFile('/server-icon-original.png')
|
||||
|
||||
if (fileData instanceof Blob) {
|
||||
const dataURL = await resizeImage(fileData, 512)
|
||||
sharedImage.value = dataURL
|
||||
localStorage.setItem(`server-icon-${serverId}`, dataURL)
|
||||
image.value = dataURL
|
||||
return
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode >= 500) {
|
||||
image.value = undefined
|
||||
return
|
||||
}
|
||||
|
||||
if (error?.statusCode === 404 && projectIconUrl) {
|
||||
try {
|
||||
const response = await fetch(projectIconUrl)
|
||||
if (!response.ok) throw new Error('Failed to fetch icon')
|
||||
const file = await response.blob()
|
||||
const originalFile = new File([file], 'server-icon-original.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
|
||||
const dataURL = await new Promise<string>((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
canvas.width = 64
|
||||
canvas.height = 64
|
||||
ctx?.drawImage(img, 0, 0, 64, 64)
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (blob) {
|
||||
const scaledFile = new File([blob], 'server-icon.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
client.kyros.files_v0
|
||||
.uploadFile('/server-icon.png', scaledFile)
|
||||
.promise.catch(() => {})
|
||||
client.kyros.files_v0
|
||||
.uploadFile('/server-icon-original.png', originalFile)
|
||||
.promise.catch(() => {})
|
||||
}
|
||||
}, 'image/png')
|
||||
const result = canvas.toDataURL('image/png')
|
||||
sharedImage.value = result
|
||||
localStorage.setItem(`server-icon-${serverId}`, result)
|
||||
resolve(result)
|
||||
URL.revokeObjectURL(img.src)
|
||||
}
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
image.value = dataURL
|
||||
return
|
||||
} catch (externalError: any) {
|
||||
console.debug('Could not process external icon:', externalError.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
image.value = undefined
|
||||
}
|
||||
|
||||
watch(upstream, () => loadImage(), { immediate: true })
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
function resizeImage(blob: Blob, size: number): Promise<string> {
|
||||
return new Promise<string>((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
ctx?.drawImage(img, 0, 0, size, size)
|
||||
const dataURL = canvas.toDataURL('image/png')
|
||||
resolve(dataURL)
|
||||
URL.revokeObjectURL(img.src)
|
||||
}
|
||||
img.src = URL.createObjectURL(blob)
|
||||
})
|
||||
}
|
||||
17
apps/frontend/src/composables/servers/use-server-project.ts
Normal file
17
apps/frontend/src/composables/servers/use-server-project.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import type { Project } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { $fetch } from 'ofetch'
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
|
||||
// TODO: Remove and use v1
|
||||
export function useServerProject(
|
||||
upstream: ComputedRef<Archon.Servers.v0.Server['upstream'] | null>,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: computed(() => ['servers', 'project', upstream.value?.project_id ?? null]),
|
||||
queryFn: () =>
|
||||
$fetch<Project>(`https://api.modrinth.com/v2/project/${upstream.value!.project_id}`),
|
||||
enabled: computed(() => !!upstream.value?.project_id),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user