mirror of
https://github.com/modrinth/code.git
synced 2026-08-03 14:45:55 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
681d08b7ec | ||
|
|
3ab2273782 | ||
|
|
3040e154ce | ||
|
|
893ec00fc6 | ||
|
|
aa7dd1d210 | ||
|
|
f8733b0488 | ||
|
|
d077d44540 | ||
|
|
4e1a61d8b6 | ||
|
|
71dee4de40 | ||
|
|
f74fad0cae | ||
|
|
07e81ac036 | ||
|
|
6e7835fb35 |
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+31
@@ -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"
|
||||
}
|
||||
Generated
-24
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user