Compare commits

...
Author SHA1 Message Date
Calum H.andGitHub 681d08b7ec Merge branch 'main' into cal/remove-roadmap-reference 2026-05-22 19:07:57 +01:00
Michael H. 3ab2273782 chore: prepr 2026-05-22 19:58:11 +02:00
Calum H.andGitHub 3040e154ce fix: remove broken roadmap reference
Signed-off-by: Calum H. <calum@modrinth.com>
2026-05-22 18:47:00 +01:00
ProspectorandGitHub 893ec00fc6 feat: add external dep sorting to moderation queue (#6161)
* feat: add external dep sorting to moderation queue

* prepr
2026-05-21 16:40:13 -07:00
Prospector aa7dd1d210 web 2026-05-21 16:23:30 -07:00
Calum H.andGitHub f8733b0488 feat: changelog 21st may 2026 (#6158)
* feat: changelog 21st may 2026

* fix: changelog
2026-05-21 22:27:14 +00:00
Calum H.andGitHub d077d44540 fix: content tab uniqueness regression (#6156)
* fix: content tab uniqueness regression

Closes: #6154

* fix: further regressions

* fix: lint

* fix: lint
2026-05-21 22:03:35 +00:00
aecsocketandGitHub 4e1a61d8b6 Adjust window occlusion threshold (#6157) 2026-05-21 21:48:39 +00:00
aecsocketandGitHub 71dee4de40 Add modpacks with external files filter to moderation queue (#6155)
* simplify query

* make it a tristate

* external deps count
2026-05-21 20:21:54 +00:00
SychicandGitHub f74fad0cae fix: typo in version changelog (#6153) 2026-05-21 19:38:11 +00:00
Truman GaoandGitHub 07e81ac036 fix: project embeds (#6152)
* fix: project embeds

* remove: params.id
2026-05-21 17:02:53 +00:00
Calum H.andGitHub 6e7835fb35 feat: implement kryos upload sessions (#6145)
* feat: implement upload sessions

* fix: files not scoped

* feat: hide staging files folder and proper cancel feedback

* fix: lint
2026-05-21 16:49:48 +00:00
37 changed files with 698 additions and 196 deletions
@@ -9,8 +9,6 @@ body:
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate feature requests
required: true
- label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com)
required: true
- type: dropdown
id: projects
attributes:
+14 -5
View File
@@ -230,8 +230,12 @@ function fileNameFromPath(path: string) {
return path.split('/').pop() ?? path
}
function getContentItemId(item: ContentItem | null | undefined) {
return item?.file_path ?? item?.file_name ?? item?.id ?? ''
}
function getContentOperationKeys(item: ContentItem) {
return [item.id, item.file_path, item.file_name, item.project?.id, item.version?.id].filter(
return [getContentItemId(item), item.file_path, item.file_name].filter(
(key): key is string => !!key,
)
}
@@ -478,10 +482,11 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
}
async function handleUpdate(id: string) {
const item = projects.value.find((p) => p.id === id)
const item = projects.value.find((p) => getContentItemId(p) === id)
if (!item?.has_update || !item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
const itemId = getContentItemId(item)
debug('handleUpdate triggered', {
fileName: item.file_name,
@@ -542,7 +547,8 @@ async function handleUpdate(id: string) {
return handleError(e)
})) as Labrinth.Versions.v2.Version[] | null
if (!isActiveUpdateRequest(requestId) || updatingProject.value?.id !== item.id) return
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
return
loadingVersions.value = false
@@ -595,6 +601,7 @@ async function handleSwitchVersion(item: ContentItem) {
if (!item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
const itemId = getContentItemId(item)
updatingModpack.value = false
updatingProject.value = item
@@ -610,7 +617,8 @@ async function handleSwitchVersion(item: ContentItem) {
return handleError(e)
})) as Labrinth.Versions.v2.Version[] | null
if (!isActiveUpdateRequest(requestId) || updatingProject.value?.id !== item.id) return
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
return
loadingVersions.value = false
@@ -1055,8 +1063,9 @@ provideContentManager({
showContentHint,
dismissContentHint,
shareItems: handleShareItems,
getItemId: getContentItemId,
mapToTableItem: (item: ContentItem) => ({
id: item.id,
id: getContentItemId(item),
project: item.project ?? {
id: item.file_name,
slug: null,
+1 -1
View File
@@ -18,7 +18,7 @@ pub struct AdsState {
const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie";
#[cfg(any(windows, target_os = "macos"))]
pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 1.0;
pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 0.5;
#[cfg(not(target_os = "linux"))]
const ADS_USER_AGENT: &str = concat!(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ",
@@ -46,7 +46,7 @@
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version changlog </span>
<span class="font-semibold text-contrast"> Version changelog </span>
<div class="w-full">
<MarkdownEditor
@@ -48,6 +48,16 @@
<span class="text-sm text-secondary">Requesting</span>
<Badge :type="queueEntry.project.requested_status" class="text-sm" />
</div>
<div
v-if="showExternalDependencies"
v-tooltip="'External dependencies'"
class="flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
>
<FileIcon aria-hidden="true" class="size-4 text-secondary" />
<span class="text-sm font-medium text-secondary">
{{ queueEntry.external_dependencies_count }}
</span>
</div>
</div>
<div v-if="queueEntry.ownership?.kind === 'user'">
<NuxtLink
@@ -119,7 +129,7 @@
</template>
<script setup lang="ts">
import { ClipboardCopyIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
import { ClipboardCopyIcon, FileIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
import {
Avatar,
Badge,
@@ -151,6 +161,7 @@ const formatDateTimeFull = useFormatDateTime({
const props = defineProps<{
queueEntry: ModerationProject
showExternalDependencies?: boolean
}>()
const emit = defineEmits<{
+4 -1
View File
@@ -199,17 +199,20 @@ export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipO
export interface ProjectWithOwnership {
ownership: ModerationOwnership
external_dependencies_count: number
[key: string]: any
}
export interface ModerationProject {
project: any
ownership: ModerationOwnership | null
external_dependencies_count: number
}
export function toModerationProjects(projects: ProjectWithOwnership[]): ModerationProject[] {
return projects.map(({ ownership, ...project }) => ({
return projects.map(({ ownership, external_dependencies_count, ...project }) => ({
project,
ownership: ownership ?? null,
external_dependencies_count: external_dependencies_count,
}))
}
@@ -20,8 +20,13 @@ const PROJECT_TYPES = [
export default defineNuxtRouteMiddleware(async (to) => {
// Only run this middleware on the server - it relies on server-only runtime config
if (import.meta.client) return
const routeProjectParam = to.params.project
const projectId = Array.isArray(routeProjectParam) ? routeProjectParam[0] : routeProjectParam
const routeType = Array.isArray(to.params.type) ? to.params.type[0] : to.params.type
// Only handle project routes
if (!to.params.id || !PROJECT_TYPES.includes(to.params.type as string)) {
if (!projectId || !routeType || !PROJECT_TYPES.includes(routeType)) {
return
}
@@ -29,7 +34,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
const authToken = useCookie('auth-token')
const client = useServerModrinthClient({ authToken: authToken.value || undefined })
const tags = useGeneratedState()
const projectId = to.params.id as string
try {
// Fetch v2 and v3 in parallel — cache both for the page's useQuery calls
+42 -10
View File
@@ -34,7 +34,7 @@
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
@select="goToPage(1)"
@@ -42,7 +42,7 @@
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<SortAscIcon
v-if="currentSortType === 'Oldest'"
v-if="currentSortType === 'Oldest' || currentSortType === 'Least external deps'"
class="size-5 flex-shrink-0 text-secondary"
/>
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
@@ -113,6 +113,7 @@
v-else
:key="item.project.id"
:queue-entry="item"
:show-external-dependencies="currentFilterType === MODPACK_FILTER_TYPE"
@start-from-project="startFromProject"
/>
</div>
@@ -246,12 +247,25 @@ const filterTypes: ComboboxOption<string>[] = [
const filterTypeValues = filterTypes.map((option) => option.value)
const DEFAULT_FILTER_TYPE = filterTypeValues[0]
const sortTypes: ComboboxOption<string>[] = [
const MODPACK_FILTER_TYPE = 'Modpacks'
const baseSortTypes: ComboboxOption<string>[] = [
{ value: 'Oldest', label: 'Oldest' },
{ value: 'Newest', label: 'Newest' },
]
const sortTypeValues = sortTypes.map((option) => option.value)
const DEFAULT_SORT_TYPE = sortTypeValues[0]
const modpackSortTypes: ComboboxOption<string>[] = [
{ value: 'Most external deps', label: 'Most external deps' },
{ value: 'Least external deps', label: 'Least external deps' },
]
const DEFAULT_SORT_TYPE = baseSortTypes[0].value
const modpackSortTypeValues = modpackSortTypes.map((option) => option.value)
const sortTypes = computed(() => {
if (currentFilterType.value === MODPACK_FILTER_TYPE) {
return [...baseSortTypes, ...modpackSortTypes]
}
return baseSortTypes
})
const itemsPerPageOptions: ComboboxOption<number>[] = [
{ value: 20, label: '20' },
@@ -269,17 +283,31 @@ function parseFilterTypeFromQuery(value: LocationQueryValue | LocationQueryValue
return filterTypeValues.includes(query) ? query : DEFAULT_FILTER_TYPE
}
function parseSortTypeFromQuery(value: LocationQueryValue | LocationQueryValue[]): string {
function parseSortTypeFromQuery(
value: LocationQueryValue | LocationQueryValue[],
filterType: string,
): string {
const query = queryAsStringOrEmpty(value)
return sortTypeValues.includes(query) ? query : DEFAULT_SORT_TYPE
const validValues = [
...baseSortTypes.map((option) => option.value),
...(filterType === MODPACK_FILTER_TYPE ? modpackSortTypeValues : []),
]
return validValues.includes(query) ? query : DEFAULT_SORT_TYPE
}
const currentFilterType = ref(parseFilterTypeFromQuery(route.query.filter))
const currentSortType = ref(parseSortTypeFromQuery(route.query.sort))
const currentSortType = ref(parseSortTypeFromQuery(route.query.sort, currentFilterType.value))
watch(
currentFilterType,
(newFilter) => {
if (
newFilter !== MODPACK_FILTER_TYPE &&
modpackSortTypeValues.includes(currentSortType.value)
) {
currentSortType.value = DEFAULT_SORT_TYPE
}
const currentQuery = { ...route.query }
if (newFilter && newFilter !== DEFAULT_FILTER_TYPE) {
currentQuery.filter = newFilter
@@ -326,7 +354,7 @@ watch(
watch(
() => route.query.sort,
(newSortParam) => {
const newValue = parseSortTypeFromQuery(newSortParam)
const newValue = parseSortTypeFromQuery(newSortParam, currentFilterType.value)
if (currentSortType.value !== newValue) {
currentSortType.value = newValue
}
@@ -423,7 +451,11 @@ const typeFiltered = computed(() => {
const filteredProjects = computed(() => {
const filtered = [...typeFiltered.value]
if (currentSortType.value === 'Oldest') {
if (currentSortType.value === 'Most external deps') {
filtered.sort((a, b) => b.external_dependencies_count - a.external_dependencies_count)
} else if (currentSortType.value === 'Least external deps') {
filtered.sort((a, b) => a.external_dependencies_count - b.external_dependencies_count)
} else if (currentSortType.value === 'Oldest') {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
@@ -0,0 +1,31 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id,\n external_dependencies_count as \"external_dependencies_count!\"\n FROM (\n SELECT DISTINCT ON (m.id)\n m.id,\n m.queued,\n (\n SELECT COUNT(*)\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE v.mod_id = m.id\n AND d.dependency_file_name IS NOT NULL\n ) external_dependencies_count\n FROM mods m\n\n /* -- Temporarily, don't exclude projects in tech rev q\n\n -- exclude projects in tech review queue\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.project_id = m.id AND didws.status = 'pending'\n */\n\n WHERE\n m.status = $1\n /* AND didws.status IS NULL */ -- Temporarily don't exclude\n\n GROUP BY m.id\n ) t\n WHERE\n ($4::boolean IS NULL OR (external_dependencies_count > 0) = $4)\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "external_dependencies_count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8",
"Bool"
]
},
"nullable": [
false,
null
]
},
"hash": "119a59fcf4bb2f19f89002c712a67c75d30056143c0bcabdbd74bb4c7b442082"
}
@@ -1,24 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id\n FROM (\n SELECT DISTINCT ON (m.id)\n m.id,\n m.queued\n FROM mods m\n\n /* -- Temporarily, don't exclude projects in tech rev q\n\n -- exclude projects in tech review queue\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.project_id = m.id AND didws.status = 'pending'\n */\n\n WHERE\n m.status = $1\n /* AND didws.status IS NULL */ -- Temporarily don't exclude\n\n GROUP BY m.id\n ) t\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "ec1f08768071d55613b0b69b3eac43e1e5d0a532171b5de7b9086ae7376f1482"
}
@@ -53,6 +53,9 @@ pub struct ProjectsRequestOptions {
/// How many projects to skip.
#[serde(default)]
pub offset: u32,
/// Whether to filter by modpacks that have external dependencies.
#[serde(default)]
pub has_external_dependencies: Option<bool>,
}
fn default_count() -> u16 {
@@ -68,6 +71,8 @@ pub struct FetchedProject {
pub project: Project,
/// Who owns the project.
pub ownership: Ownership,
/// How many external file dependencies the project has.
pub external_dependencies_count: i64,
}
/// Fetched information on who owns a project.
@@ -190,13 +195,22 @@ pub async fn get_projects_internal(
use futures::stream::TryStreamExt;
let project_ids = sqlx::query!(
"
SELECT id
let project_rows = sqlx::query!(
r#"
SELECT
id,
external_dependencies_count as "external_dependencies_count!"
FROM (
SELECT DISTINCT ON (m.id)
m.id,
m.queued
m.queued,
(
SELECT COUNT(*)
FROM versions v
INNER JOIN dependencies d ON d.dependent_id = v.id
WHERE v.mod_id = m.id
AND d.dependency_file_name IS NOT NULL
) external_dependencies_count
FROM mods m
/* -- Temporarily, don't exclude projects in tech rev q
@@ -212,20 +226,36 @@ pub async fn get_projects_internal(
GROUP BY m.id
) t
WHERE
($4::boolean IS NULL OR (external_dependencies_count > 0) = $4)
ORDER BY queued ASC
OFFSET $3
LIMIT $2
",
"#,
ProjectStatus::Processing.as_str(),
request_opts.count as i64,
request_opts.offset as i64
request_opts.offset as i64,
request_opts.has_external_dependencies,
)
.fetch(&**pool)
.map_ok(|m| database::models::DBProjectId(m.id))
.try_collect::<Vec<database::models::DBProjectId>>()
.try_collect::<Vec<_>>()
.await
.wrap_internal_err("failed to fetch projects awaiting review")?;
let project_ids = project_rows
.iter()
.map(|m| database::models::DBProjectId(m.id))
.collect::<Vec<_>>();
let project_metadata = project_rows
.into_iter()
.map(|m| {
(
database::models::DBProjectId(m.id),
m.external_dependencies_count,
)
})
.collect::<HashMap<_, _>>();
let projects =
database::DBProject::get_many_ids(&project_ids, &**pool, &redis)
.await
@@ -240,7 +270,16 @@ pub async fn get_projects_internal(
let map_project =
|(project, ownership): (Project, Ownership)| -> FetchedProject {
FetchedProject { ownership, project }
let external_dependencies_count = project_metadata
.get(&database::models::DBProjectId(project.id.0 as i64))
.copied()
.unwrap_or_default();
FetchedProject {
ownership,
project,
external_dependencies_count,
}
};
let projects = projects
@@ -61,6 +61,7 @@ pub async fn get_projects(
web::Query(internal::moderation::ProjectsRequestOptions {
count: count.count,
offset: 0,
has_external_dependencies: None,
}),
session_queue,
)
+2
View File
@@ -11,6 +11,7 @@ import { ISO3166Module } from './iso3166'
import { KyrosContentV1Module } from './kyros/content/v1'
import { KyrosFilesV0Module } from './kyros/files/v0'
import { KyrosLogsV1Module } from './kyros/logs/v1'
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
@@ -71,6 +72,7 @@ export const MODULE_REGISTRY = {
kyros_content_v1: KyrosContentV1Module,
kyros_files_v0: KyrosFilesV0Module,
kyros_logs_v1: KyrosLogsV1Module,
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
labrinth_auth_internal: LabrinthAuthInternalModule,
labrinth_auth_v2: LabrinthAuthV2Module,
@@ -14,6 +14,7 @@ export class KyrosContentV1Module extends AbstractModule {
* @param files - Files to upload as addons
* @param options - Optional progress callback
* @returns UploadHandle with promise, onProgress, and cancel
* @deprecated Use `kyros.upload_sessions_v1` so cancellation can remove staged addon files before finalize.
*/
public uploadAddonFile(
worldId: string,
@@ -94,6 +94,7 @@ export class KyrosFilesV0Module extends AbstractModule {
* @param file - File to upload
* @param options - Optional progress callback and feature overrides
* @returns UploadHandle with promise, onProgress, and cancel
* @deprecated Use `kyros.upload_sessions_v1` for bulk uploads so cancellation can remove staged files before finalize.
*/
public uploadFile(
path: string,
@@ -1,4 +1,32 @@
export namespace Kyros {
export namespace UploadSessions {
export namespace v1 {
export type Scope = 'content' | 'files'
export type UploadSessionStatus =
| 'active'
| 'uploading'
| 'finalizing'
| 'cancelled'
| 'finalized'
| 'expired'
export interface UploadSessionResponse {
upload_id: string
status: UploadSessionStatus
created_at: number
updated_at: number
last_upload_at: number | null
expires_at: number
entry_count: number
uploaded_byte_count: number
}
export interface GetUploadSessionResponse {
session: UploadSessionResponse | null
}
}
}
export namespace Files {
export namespace v0 {
export interface DirectoryItem {
@@ -0,0 +1,104 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Kyros } from '../types'
export type UploadSessionFile = {
file: File | Blob
filename: string
}
export class KyrosUploadSessionsV1Module extends AbstractModule {
public getModuleID(): string {
return 'kyros_upload_sessions_v1'
}
public async create(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`,
{
api: '',
version: 'v1',
method: 'POST',
useNodeAuth: true,
},
)
}
public async get(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
): Promise<Kyros.UploadSessions.v1.GetUploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.GetUploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`,
{
api: '',
version: 'v1',
method: 'GET',
useNodeAuth: true,
},
)
}
public uploadFiles(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
files: UploadSessionFile[],
options?: {
onProgress?: (progress: UploadProgress) => void
retry?: boolean | number
},
): UploadHandle<Kyros.UploadSessions.v1.UploadSessionResponse> {
const formData = new FormData()
for (const { file, filename } of files) {
formData.append('file', file, filename)
}
return this.client.upload<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}/files`,
{
api: '',
version: 'v1',
formData,
onProgress: options?.onProgress,
retry: options?.retry,
useNodeAuth: true,
},
)
}
public async finalize(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}/finalize`,
{
api: '',
version: 'v1',
method: 'POST',
useNodeAuth: true,
},
)
}
public async cancel(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}`,
{
api: '',
version: 'v1',
method: 'DELETE',
useNodeAuth: true,
},
)
}
}
@@ -32,12 +32,12 @@ use std::io::Cursor;
/// Content item with rich metadata for frontend display
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContentItem {
/// Unique identifier (the file name)
/// Display file name.
pub file_name: String,
/// Relative path to the file within the profile
pub file_path: String,
/// Stable frontend identifier (SHA1 hash of file content, survives renames).
/// Not a project or version ID.
/// SHA1 hash of file content. Stable across renames, but not unique when
/// duplicate files have identical contents.
pub id: String,
/// File size in bytes
pub size: u64,
+20
View File
@@ -10,6 +10,26 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-05-21T22:13:35+00:00`,
product: 'web',
body: `## Fixed
- Fixed project embeds not loading in correctly.`,
},
{
date: `2026-05-21T22:13:35+00:00`,
product: 'app',
version: '0.13.21',
body: `## Fixed
- Fixed issue with content items visually duplicating over other content items in the Content tab table.`,
},
{
date: `2026-05-21T22:13:35+00:00`,
product: 'hosting',
body: `## Fixed
- Fixed issue with content items visually duplicating over other content items in the Content tab table.
- Fixed issue when cancelling a multi-file upload to the Files tab or Content tab, any files fully uploaded before cancelling are not removed on cancel.`,
},
{
date: `2026-05-20T19:48:04+00:00`,
product: 'web',
@@ -92,6 +92,7 @@ const filesBusyHeader = computed(() =>
const dismissedIds = reactive(new Set<string>())
const cancellingIds = reactive(new Set<string>())
const uploadCancelling = ref(false)
const dismissedContentErrorKey = ref<string | null>(null)
const contentErrorKey = computed(() =>
@@ -327,6 +328,21 @@ async function onBackupRetry(item: BackupAdmonitionEntry) {
await invalidate()
}
async function onUploadCancel() {
if (uploadCancelling.value) return
const cancel = ctx.cancelUpload.value
if (!cancel) return
uploadCancelling.value = true
try {
await cancel()
} catch (err) {
console.error('Failed to cancel upload', err)
} finally {
uploadCancelling.value = false
}
}
async function onDismissAll() {
const tasks: Promise<unknown>[] = []
for (const it of stackItems.value) {
@@ -375,7 +391,12 @@ function onContentErrorDismiss() {
@dismiss="onContentErrorDismiss"
@retry="emit('content-retry')"
/>
<UploadAdmonition v-else-if="item.kind === 'upload'" />
<UploadAdmonition
v-else-if="item.kind === 'upload'"
:cancelable="!!ctx.cancelUpload.value"
:cancelling="uploadCancelling"
@cancel="onUploadCancel"
/>
<FileOperationAdmonition
v-else-if="item.kind === 'fs-op'"
:op="item.op"
@@ -15,9 +15,11 @@
Math.round(overallProgress * 100)
}}%)
</span>
<template v-if="cancelUpload" #top-right-actions>
<template v-if="cancelable" #top-right-actions>
<ButtonStyled type="outlined" color="blue">
<button class="!border" type="button" @click="cancelUpload()">Cancel</button>
<button class="!border" type="button" :disabled="cancelling" @click="$emit('cancel')">
Cancel
</button>
</ButtonStyled>
</template>
</Admonition>
@@ -32,12 +34,26 @@ import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { useFormatBytes } from '#ui/composables'
import { injectModrinthServerContext } from '#ui/providers'
withDefaults(
defineProps<{
cancelable?: boolean
cancelling?: boolean
}>(),
{
cancelable: true,
cancelling: false,
},
)
defineEmits<{
cancel: []
}>()
const formatBytes = useFormatBytes()
const ctx = injectModrinthServerContext()
const state = computed(() => ctx.uploadState.value)
const cancelUpload = computed(() => ctx.cancelUpload.value)
const overallProgress = computed(() => {
const s = state.value
@@ -0,0 +1,212 @@
import type {
AbstractModrinthClient,
Kyros,
UploadProgress,
UploadState,
} from '@modrinth/api-client'
import type { Ref } from 'vue'
import type { CancelUploadHandler } from '#ui/providers/server-context'
export type UploadSessionUploadFile = {
file: File
filename: string
}
export type UploadSessionUploadResult = 'completed' | 'cancelled'
export function useUploadSessionUpload(options: {
client: AbstractModrinthClient
scope: Kyros.UploadSessions.v1.Scope
worldId: Ref<string | null>
uploadState: Ref<UploadState>
cancelUpload: Ref<CancelUploadHandler | null>
}) {
let activeUploadCancel: CancelUploadHandler | null = null
function getUploadByteCount(files: File[]) {
return files.reduce((sum, file) => sum + file.size, 0)
}
function resetUploadState() {
options.uploadState.value = {
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
}
}
function startUploadState(files: File[]) {
options.uploadState.value = {
isUploading: true,
currentFileName: files[0]?.name ?? null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: getUploadByteCount(files),
completedFiles: 0,
totalFiles: files.length,
}
}
function setUploadProgressFromBytes(files: File[], uploadedBytes: number) {
const totalBytes = getUploadByteCount(files)
const boundedUploadedBytes = Math.max(0, Math.min(totalBytes, uploadedBytes))
let previousBytes = 0
for (let i = 0; i < files.length; i++) {
const file = files[i]
const nextBytes = previousBytes + file.size
if (boundedUploadedBytes >= nextBytes) {
previousBytes = nextBytes
continue
}
options.uploadState.value.currentFileName = file.name
options.uploadState.value.currentFileProgress =
file.size === 0 ? 1 : (boundedUploadedBytes - previousBytes) / file.size
options.uploadState.value.uploadedBytes = boundedUploadedBytes
options.uploadState.value.totalBytes = totalBytes
options.uploadState.value.completedFiles = i
return
}
options.uploadState.value.currentFileName =
files.length > 0 ? files[files.length - 1].name : null
options.uploadState.value.currentFileProgress = files.length > 0 ? 1 : 0
options.uploadState.value.uploadedBytes = totalBytes
options.uploadState.value.totalBytes = totalBytes
options.uploadState.value.completedFiles = files.length
}
function setUploadProgressFromXhr(files: File[], progress: UploadProgress) {
const totalBytes = getUploadByteCount(files)
const uploadedBytes =
progress.total > 0
? Math.round(totalBytes * progress.progress)
: Math.min(progress.loaded, totalBytes)
setUploadProgressFromBytes(files, uploadedBytes)
}
async function cancelUploadSession(worldId: string, uploadId: string) {
try {
await options.client.kyros.upload_sessions_v1.cancel(options.scope, worldId, uploadId)
} catch {
// ...
}
}
async function cancelUpload() {
await activeUploadCancel?.()
}
async function uploadFiles(files: UploadSessionUploadFile[]): Promise<UploadSessionUploadResult> {
if (files.length === 0) return 'cancelled'
if (options.uploadState.value.isUploading) return 'cancelled'
const worldId = options.worldId.value
if (!worldId) return 'cancelled'
const sourceFiles = files.map(({ file }) => file)
startUploadState(sourceFiles)
let cancelled = false
let finalized = false
let uploadId: string | null = null
let uploadHandle: { cancel: () => void } | null = null
let cancelRequest: Promise<void> | null = null
let cancelCompletion: Promise<void> | null = null
let resolveCancelCompletion: (() => void) | null = null
const waitForCancelCompletion = () => {
cancelCompletion ??= new Promise<void>((resolve) => {
resolveCancelCompletion = resolve
})
return cancelCompletion
}
const completeCancel = () => {
resolveCancelCompletion?.()
resolveCancelCompletion = null
cancelCompletion = null
}
const cancelSessionOnce = async () => {
if (!uploadId) return
cancelRequest ??= cancelUploadSession(worldId, uploadId)
await cancelRequest
}
const finishCancellation = async () => {
await cancelSessionOnce()
completeCancel()
}
const cancelCurrentUpload = async () => {
cancelled = true
uploadHandle?.cancel()
if (!uploadId) {
await waitForCancelCompletion()
return
}
await finishCancellation()
}
activeUploadCancel = cancelCurrentUpload
options.cancelUpload.value = cancelCurrentUpload
try {
const session = await options.client.kyros.upload_sessions_v1.create(options.scope, worldId)
uploadId = session.upload_id
if (cancelled) {
await finishCancellation()
return 'cancelled'
}
uploadHandle = options.client.kyros.upload_sessions_v1.uploadFiles(
options.scope,
worldId,
uploadId,
files,
{
onProgress: (progress) => setUploadProgressFromXhr(sourceFiles, progress),
},
)
await uploadHandle.promise
if (cancelled) {
await finishCancellation()
return 'cancelled'
}
setUploadProgressFromBytes(sourceFiles, getUploadByteCount(sourceFiles))
await options.client.kyros.upload_sessions_v1.finalize(options.scope, worldId, uploadId)
finalized = true
return 'completed'
} catch (error) {
if (uploadId && !finalized) {
await finishCancellation()
} else if (cancelled) {
completeCancel()
}
if (cancelled || (error instanceof Error && error.message === 'Upload cancelled')) {
return 'cancelled'
}
throw error
} finally {
if (activeUploadCancel === cancelCurrentUpload) {
activeUploadCancel = null
}
if (options.cancelUpload.value === cancelCurrentUpload) {
options.cancelUpload.value = null
}
if (cancelled) {
completeCancel()
}
resetUploadState()
}
}
return {
cancelUpload,
uploadFiles,
}
}
@@ -10,7 +10,7 @@ import { computed, ref } from 'vue'
import type { FileOperation } from '../layouts/shared/files-tab/types'
import { injectModrinthClient, provideModrinthServerContext } from '../providers'
import type { BusyReason } from '../providers/server-context'
import type { BusyReason, CancelUploadHandler } from '../providers/server-context'
import { defineMessage } from './i18n'
import { useModrinthServersConsole } from './server-console'
@@ -355,7 +355,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
completedFiles: 0,
totalFiles: 0,
})
const cancelUpload = ref<(() => void) | null>(null)
const cancelUpload = ref<CancelUploadHandler | null>(null)
type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
const dismissedOpIds = ref<Set<string>>(new Set())
@@ -84,9 +84,13 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
const start = Math.floor(relativeScrollTop / itemHeight)
const visibleCount = Math.ceil(viewportHeight.value / itemHeight)
const rangeSize = visibleCount + bufferSize * 2
const rangeStart = Math.max(0, start - bufferSize)
const rangeEnd = Math.min(items.value.length, start + visibleCount + bufferSize * 2)
const rangeStart = Math.min(
Math.max(0, start - bufferSize),
Math.max(0, items.value.length - rangeSize),
)
const rangeEnd = Math.min(items.value.length, rangeStart + rangeSize)
return {
start: rangeStart,
@@ -74,6 +74,7 @@ interface Props {
bulkTotal?: number
bulkWaiting?: boolean
ariaLabel?: string
getItemId?: (item: ContentItem) => string
}
const props = withDefaults(defineProps<Props>(), {
@@ -85,6 +86,7 @@ const props = withDefaults(defineProps<Props>(), {
bulkTotal: 0,
bulkWaiting: false,
ariaLabel: undefined,
getItemId: undefined,
})
const emit = defineEmits<{
@@ -102,6 +104,10 @@ const iconStackWidth = computed(() => {
return 32 + (visibleItems.value.length - 1 + (overflowCount.value > 0 ? 1 : 0)) * iconStackOffset
})
function resolveItemId(item: ContentItem) {
return props.getItemId?.(item) ?? item.file_path ?? item.file_name ?? item.id
}
const allDisabled = computed(() => props.selectedItems.every((m) => !m.enabled))
const allEnabled = computed(() => props.selectedItems.every((m) => m.enabled))
@@ -146,7 +152,7 @@ const bulkProgressMessage = computed(() => {
>
<div
v-for="(item, index) in visibleItems"
:key="item.id"
:key="resolveItemId(item)"
v-tooltip="item.project?.title ?? item.file_name"
class="absolute top-0 flex h-8 w-8 items-center justify-center overflow-hidden rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4"
:style="{ left: `${index * iconStackOffset}px`, zIndex: visibleItems.length - index }"
@@ -154,7 +160,7 @@ const bulkProgressMessage = computed(() => {
<Avatar
:src="item.project?.icon_url"
:alt="item.project?.title ?? item.file_name"
:tint-by="item.id"
:tint-by="resolveItemId(item)"
size="100%"
no-shadow
class="selected-content-avatar"
@@ -3,21 +3,27 @@ import { computed, ref, watch } from 'vue'
import type { ContentItem } from '../types'
export function useContentSelection(items: Ref<ContentItem[]>) {
export function useContentSelection(
items: Ref<ContentItem[]>,
getItemId: (item: ContentItem) => string,
) {
const selectedIds = ref<string[]>([])
const selectedItems = computed(() =>
items.value.filter((item) => selectedIds.value.includes(item.id)),
items.value.filter((item) => selectedIds.value.includes(getItemId(item))),
)
watch(items, (newItems) => {
if (selectedIds.value.length === 0) return
const validIds = new Set(newItems.map((item) => item.id))
const pruned = selectedIds.value.filter((id) => validIds.has(id))
if (pruned.length !== selectedIds.value.length) {
selectedIds.value = pruned
}
})
watch(
() => items.value.map(getItemId),
(newIds) => {
if (selectedIds.value.length === 0) return
const validIds = new Set(newIds)
const pruned = selectedIds.value.filter((id) => validIds.has(id))
if (pruned.length !== selectedIds.value.length) {
selectedIds.value = pruned
}
},
)
function clearSelection() {
selectedIds.value = []
@@ -151,6 +151,10 @@ const messages = defineMessages({
const ctx = injectContentManager()
function getItemId(item: ContentItem) {
return ctx.getItemId?.(item) ?? item.file_path ?? item.file_name ?? item.id
}
type SortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'date-added-newest' | 'date-added-oldest'
const sortMode = ref<SortMode>('alphabetical-asc')
@@ -227,6 +231,7 @@ const { selectedFilters, filterOptions, toggleFilter, applyFilters } = useConten
const { selectedIds, selectedItems, clearSelection, removeFromSelection } = useContentSelection(
ctx.items,
getItemId,
)
const { isBulkOperating, bulkProgress, bulkTotal, bulkOperation, runBulk } = useBulkOperation()
@@ -261,13 +266,12 @@ const filteredItems = computed(() => {
const tableItems = computed<ContentCardTableItem[]>(() => {
const items = filteredItems.value.map((item) => {
const base = ctx.mapToTableItem(item)
const id = getItemId(item)
return {
...base,
id,
disabled:
isChanging(base.id) ||
ctx.isBusy.value ||
isBulkOperating.value ||
item.installing === true,
isChanging(id) || ctx.isBusy.value || isBulkOperating.value || item.installing === true,
installing: item.installing === true,
hasUpdate: item.has_update,
isClientOnly:
@@ -314,7 +318,7 @@ const pendingDeletionItems = ref<ContentItem[]>([])
const confirmDeletionModal = ref<InstanceType<typeof ConfirmDeletionModal>>()
function handleDeleteById(id: string, event?: MouseEvent) {
const item = ctx.items.value.find((i) => i.id === id)
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (item) {
pendingDeletionItems.value = [item]
if (event?.shiftKey) {
@@ -356,11 +360,14 @@ async function confirmDelete() {
if (itemsToDelete.length === 1) {
const item = itemsToDelete[0]
const id = item.id
const id = getItemId(item)
markChanging(id)
await ctx.deleteItem(item)
removeFromSelection(id)
unmarkChanging(id)
try {
await ctx.deleteItem(item)
removeFromSelection(id)
} finally {
unmarkChanging(id)
}
return
}
@@ -369,14 +376,14 @@ async function confirmDelete() {
itemsToDelete,
async (item) => {
await ctx.deleteItem(item)
removeFromSelection(item.id)
removeFromSelection(getItemId(item))
},
{ onComplete: clearSelection },
)
}
async function handleToggleEnabledById(id: string, _value: boolean) {
const item = ctx.items.value.find((i) => i.id === id)
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (!item) return
markChanging(id)
try {
@@ -431,7 +438,7 @@ function handleUpdateById(id: string) {
}
function handleSwitchVersionById(id: string) {
const item = ctx.items.value.find((i) => i.id === id)
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (item) {
ctx.switchVersion?.(item)
}
@@ -758,6 +765,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:bulk-total="bulkTotal"
:bulk-waiting="bulkWaiting"
:aria-label="formatMessage(commonMessages.selectionActionsLabel)"
:get-item-id="getItemId"
@clear="clearSelection"
@enable="bulkEnable"
@disable="bulkDisable"
@@ -77,6 +77,9 @@ export interface ContentManagerContext {
// Share support (optional — when undefined, share button becomes hidden entirely)
shareItems?: (items: ContentItem[], format: 'names' | 'file-names' | 'urls' | 'markdown') => void
// Stable per-row identity. ContentItem.id can be a content hash, so it is not always unique.
getItemId?: (item: ContentItem) => string
// Bulk operation guard — set by layout, checked by providers to suppress refreshes
isBulkOperating?: Ref<boolean>
@@ -258,6 +258,7 @@ import BackupDeleteModal from '#ui/components/servers/backups/BackupDeleteModal.
import BackupItem from '#ui/components/servers/backups/BackupItem.vue'
import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue'
import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue'
import { useBackupsSelection } from '#ui/composables/hosting/backups-selection'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { useBulkOperation } from '#ui/layouts/shared/content-tab/composables/bulk-operations'
@@ -268,8 +269,6 @@ import {
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import { useBackupsSelection } from './backups-selection'
const messages = defineMessages({
selectAll: {
id: 'servers.backups.toolbar.select-all',
@@ -7,6 +7,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
@@ -113,6 +114,13 @@ const messages = defineMessages({
const client = injectModrinthClient()
const { server, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
injectModrinthServerContext()
const contentUploadSession = useUploadSessionUpload({
client,
scope: 'content',
worldId,
uploadState,
cancelUpload,
})
const { addNotification } = injectNotificationManager()
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
const route = useRoute()
@@ -518,6 +526,10 @@ function getContentItemDisplayKey(item: ContentItem) {
return item.project?.id ?? item.file_name ?? item.id
}
function getContentItemId(item: ContentItem) {
return item.file_name ?? item.id
}
function mergeFragileContentItems(items: ContentItem[]) {
const nextItems = new Map(items.map((item) => [getContentItemDisplayKey(item), item]))
const mergedItems = displayedContentItems.value.map((item) => {
@@ -812,47 +824,17 @@ function handleUploadFiles() {
const wid = worldId.value
if (!wid) return
uploadState.value = {
isUploading: true,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: files.reduce((sum, f) => sum + f.size, 0),
completedFiles: 0,
totalFiles: files.length,
}
const handle = client.kyros.content_v1.uploadAddonFile(wid, files, {
onProgress: (p) => {
uploadState.value.currentFileProgress = p.progress
uploadState.value.uploadedBytes = p.loaded
uploadState.value.totalBytes = p.total
},
})
cancelUpload.value = () => handle.cancel()
try {
await handle.promise
uploadState.value.completedFiles = files.length
await contentQuery.refetch()
const result = await contentUploadSession.uploadFiles(
files.map((file) => ({ file, filename: file.name })),
)
if (result === 'completed') await contentQuery.refetch()
} catch (err) {
if (err instanceof Error && err.message === 'Upload cancelled') return
addNotification({
type: 'error',
title: formatMessage(messages.failedToUpload),
text: err instanceof Error ? err.message : undefined,
})
} finally {
cancelUpload.value = null
uploadState.value = {
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
}
}
}
input.click()
@@ -1002,7 +984,7 @@ async function handleBulkUpdate(items: ContentItem[]) {
}
async function handleUpdateItem(id: string) {
const item = contentItems.value.find((i) => i.id === id)
const item = contentItems.value.find((i) => getContentItemId(i) === id)
if (!item?.has_update || !item.project?.id || !item.version?.id) return
updatingModpack.value = false
@@ -1242,13 +1224,14 @@ provideContentManager({
openSettings: () => openServerSettings({ tabId: 'installation' }),
switchVersion: handleSwitchVersion,
getOverflowOptions,
getItemId: getContentItemId,
mapToTableItem: (item) => {
const projectType = item.project_type ?? type.value
const addon = addonLookup.value.get(item.file_name)
const hasModrinthProject = !!addon?.project_id || (!!item.installing && !!item.project?.id)
const projectSlugOrId = item.project.slug ?? item.project.id
return {
id: item.id,
id: getContentItemId(item),
project: item.project,
projectLink: hasModrinthProject ? `/${projectType}/${projectSlugOrId}` : undefined,
version: item.version,
@@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import { useReadyState } from '#ui/composables'
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
import { useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
@@ -25,7 +26,21 @@ const props = defineProps<{
const client = injectModrinthClient()
const serverContext = injectModrinthServerContext()
const { serverId, fsOps, busyReasons, uploadState, cancelUpload: cancelUploadRef } = serverContext
const {
serverId,
worldId,
fsOps,
busyReasons,
uploadState,
cancelUpload: cancelUploadRef,
} = serverContext
const fileUploadSession = useUploadSessionUpload({
client,
scope: 'files',
worldId,
uploadState,
cancelUpload: cancelUploadRef,
})
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -113,7 +128,13 @@ const {
staleTime: 30_000,
})
const items = computed<FileItem[]>(() => directoryData.value?.items ?? [])
function isVisibleFileItem(item: Kyros.Files.v0.DirectoryItem) {
return !item.path.split('/').includes('.modrinth-staged')
}
const items = computed<FileItem[]>(() =>
(directoryData.value?.items ?? []).filter(isVisibleFileItem),
)
const filesReadyPending = useReadyState({ isLoading, data: directoryData })
@@ -365,71 +386,33 @@ async function restartServer() {
await client.archon.servers_v0.power(serverId, 'Restart')
}
let activeUploadCancel: (() => void) | null = null
function getSessionUploadFilename(fileName: string) {
const basePath = currentPath.value.split('/').filter(Boolean).join('/')
return basePath ? `${basePath}/${fileName}` : fileName
}
async function uploadFiles(files: File[]) {
if (files.length === 0) return
const totalBytes = files.reduce((sum, f) => sum + f.size, 0)
uploadState.value = {
isUploading: true,
currentFileName: files[0].name,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes,
completedFiles: 0,
totalFiles: files.length,
}
cancelUploadRef.value = () => activeUploadCancel?.()
let completedBytes = 0
for (let i = 0; i < files.length; i++) {
const file = files[i]
const filePath = `${currentPath.value}/${file.name}`.replace('//', '/')
uploadState.value.currentFileName = file.name
uploadState.value.currentFileProgress = 0
try {
const uploader = client.kyros.files_v0.uploadFile(filePath, file, {
onProgress: ({ progress }) => {
uploadState.value.currentFileProgress = progress
uploadState.value.uploadedBytes = completedBytes + Math.round(file.size * progress)
},
})
activeUploadCancel = () => uploader.cancel()
await uploader.promise
completedBytes += file.size
uploadState.value.completedFiles = i + 1
uploadState.value.uploadedBytes = completedBytes
} catch (err) {
if (err instanceof Error && err.message === 'Upload cancelled') break
addNotification({
title: formatMessage(commonMessages.uploadFailedLabel),
text: `Failed to upload ${file.name}`,
type: 'error',
})
}
}
activeUploadCancel = null
cancelUploadRef.value = null
refreshList()
uploadState.value = {
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
try {
const result = await fileUploadSession.uploadFiles(
files.map((file) => ({
file,
filename: getSessionUploadFilename(file.name),
})),
)
if (result === 'completed') refreshList()
} catch (err) {
addNotification({
title: formatMessage(commonMessages.uploadFailedLabel),
text: err instanceof Error ? err.message : undefined,
type: 'error',
})
}
}
function cancelUpload() {
activeUploadCancel?.()
fileUploadSession.cancelUpload()
}
// Provide the file manager context
@@ -35,17 +35,15 @@
</template>
<script setup lang="ts">
// No ReadyTransition wrapper: console and ServerManageStats own their loading UX; there is no single TanStack "ready" gate for this tab.
import type { Mclogs } from '@modrinth/api-client'
import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import ServerManageStats from '#ui/components/servers/ServerManageStats.vue'
import { useModrinthServersConsole } from '#ui/composables'
import { ConsolePageLayout, provideConsoleManager } from '#ui/layouts/shared/console'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
import ServerManageStats from './components/ServerManageStats.vue'
const props = withDefaults(
defineProps<{
showAdvancedDebugInfo?: boolean
+3 -1
View File
@@ -16,6 +16,8 @@ export interface FilesystemAuth {
token: string
}
export type CancelUploadHandler = () => void | Promise<void>
export interface ModrinthServerContext {
readonly serverId: string
readonly worldId: Ref<string | null>
@@ -44,7 +46,7 @@ export interface ModrinthServerContext {
// File upload state
readonly uploadState: Ref<UploadState>
readonly cancelUpload: Ref<(() => void) | null>
readonly cancelUpload: Ref<CancelUploadHandler | null>
// File operations (extract, move, etc.)
readonly activeOperations: ComputedRef<FileOperation[]>
@@ -5,7 +5,7 @@ import { computed, ref } from 'vue'
import EditServerIcon from '../../components/servers/edit-server-icon/EditServerIcon.vue'
import { provideModrinthServerContext } from '../../providers'
import type { ModrinthServerContext } from '../../providers/server-context'
import type { CancelUploadHandler, ModrinthServerContext } from '../../providers/server-context'
const meta = {
title: 'Servers/EditServerIcon',
@@ -73,7 +73,7 @@ const meta = {
fsQueuedOps: ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([]),
refreshFsAuth: async () => {},
uploadState,
cancelUpload: ref(null),
cancelUpload: ref<CancelUploadHandler | null>(null),
activeOperations: computed(() => []),
dismissOperation: async () => {},
}
@@ -8,7 +8,7 @@ import ServerPanelAdmonitions from '../../components/servers/admonitions/ServerP
import { defineMessage } from '../../composables/i18n'
import type { FileOperation } from '../../layouts/shared/files-tab/types'
import { provideModrinthServerContext } from '../../providers'
import type { ModrinthServerContext } from '../../providers/server-context'
import type { CancelUploadHandler, ModrinthServerContext } from '../../providers/server-context'
const meta = {
title: 'Servers/ServerPanelAdmonitions',
@@ -92,7 +92,8 @@ const meta = {
fsQueuedOps: ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([]),
refreshFsAuth: async () => {},
uploadState,
cancelUpload: ref(() => {
cancelUpload: ref<CancelUploadHandler | null>(async () => {
await new Promise((resolve) => setTimeout(resolve, 1200))
uploadState.value = { ...uploadState.value, isUploading: false }
}),
activeOperations: computed(() => fileOp.value),