feat: managing project versions (#4811)

* start modal, working show modal

* add stages and implement MultiModalStage component

* add project versions context and add file button

* implement add files stage

* export interfaces

* move MultiStageModal to /base

* small update to file input

* add version types to api-client

* wrap version namespace under v3

* implement add details stage fields and loaders component

* start create MC versions stage

* implement changelog stage and bring width into a per stage concern

* implement loader picker with grouping

* improve grouping and sorting for loader picker

* use chips component

* small updaets

* fix loader icon color

* componentize mc version picker

* initial version of shift click to select range

* use newModal for markdown editor

* start add dependencies stage with search

* implement showing mod options in search

* componentize modselect and add version/dependency relation select

* hide version and dependency relation when no project selected

* fix project facet search

* implement api-client versions requests

* fix search api request facet type to be string

* fix new modal outer container scroll

* implement add dependency stage

* fix parse error

* add placeholders

* fix types

* update dependency row styles

* small change

* fix the types on manage versions to be correct with labrinth request bodies

* fix create version file parts

* use draft version ref in flow and implement proper file handlling

* use draft version ref for mc versions select

* implement reactive modal state and conditionally disabled next buttons

* ensure all data is using draftVersion ref

* remove shift click to select range since it sucks

* fix up add dependencies stage state/types

* small fixes

* implement adding dependencies connected to api calls and make adding dependencies work

* add final create version button config

* start create version backend call and bring versions table to project settings

* set add files stage width

* remove version file upload in project page

* small fix

* fix create version api call

* implement error handling

* implement mc versions search

* implement showing all mc versions

* small fix

* implement prefill data

* add success notification

* add cancel button

* add new dropzone file input

* run pnpm run fix

* add tailwind preset in ui package

* polish file version row

* fix modal widths

* hide added versions when no versions added

* implement add loaders stage

* implement small chips and small fixes

* implement grouping for all releases

* implement new all releases grouping

* implement better shift click for version select

* small fixes

* fix search input style

* delete versions provider and start project type inferring

* implement getting project type

* add versions empty state, add folder up icon and pnpm run fix

* implement create version in project versions table

* update side nav

* implement dynamic create version flow depending on project type and detected data

* add id to stages and fix calling setStage not working

* move added loaded out of loader picker

* remove selected and detected MC versions

* add loading message to dependency search and fix dependency type always being "required"

* fix components in ref

* fix width on dropdown

* implement toggle all mc versions based on state of last in range

* fix mc version text colour

* do proper clean up

* update loaders to use tag item

* update UI to use TagItem and better match styles

* handle detected data when setting primary file

* add progress bar

* hide progress bar for non-progress stage

* add loading state on submit

* properly cache dependencies projects/versions

* pnpm run fix

* add dragover show purple border on dropzone file input

* better handle added dependencies

* move versions in side nav

* implement adding file type

* fix api body format for file type

* implement working edit existing version
- working add/remove file
- working edit version details

* a step towards proper versions refresh

* add gallery to project settings

* actually figured out refresh versions

* move checklist into settings page

* remove editing version from version page and add button to versions table in project settings

* remove edit and delete buttons from gallery in project page

* add empty state messages for project page

* add default scroll bar styles

* implement support for new file types

* remove edit from dropdown in project page versions table

* redirect to settings page

* move changelog to row with actions

* fix overflow on added dependencies

* fix redirect

* update scroll styles

* implement add environment stage (create and modify version not persisting environment to backend)

* small style fixes

* small spacing fix

* small style fixes

* add a flag for loading dependency projects

* address PR comments

* fix modrinth ui imports

* use magic keys instead of window.addeventlistener

* add spacing in bottom of settings page

* useDebounceFn from vue

* fix inconsistent stroke

* persist scroll through

* fix remove button

* fix api fields

* fix version file dropdown: hide primary option in edit mode and fix setting initial value

* fix links in nags

* implement skipped field for skipping steps instead of mutating stages array's elements

* implement suggested dependencies components

* implement suggested dependencies api call

* refactor cached get project and get version calls

* always hide environments

* update links

* set scroll in 10ms

* update links

* fix links pt2

* fix shadow

* fix progress bar

* dont include mc versions in suggested versions finder

* fix text overflow styles

* use tooltip

* fix change version name api

* implement set environment api call

* delete unused vue pages

* implement detected environment, edit environment step, and fix showing loaders in details for no loader projects

* small fix

* no loaders project wrong check

* fix not having 'minecraft' loader for resource pack

* implement updating existing files file type

* move add minecraft loader outside try catch

* add datapack to have environment

* fix being able to select duplicate MC versions

* remove datapack project from environment

* fix version fetch

* fix having detected environment not properly skipping step

* only add detected data when primary file changes

* fix unknown environemtn

* implement gallery and versions have moved admonition

* update project page for creator view

* small copy update

* merge fixes

* pnpm run fix

* fix checkmark squished

* fix version type can be deselected

* refactor: DI context + better typed MultistageModal

* fix type import

* Misc QA fixes

* fix allowed file types with no project type

* implement new add files stage

* fix versiosn header with new pagination

* hide buttons when no files for add file stage

* use prettier formatter

* allow signature file types

* add detecting primary file

* fix progress bar in firefox

* fix environment not correctly being hidden/shown

* remove environment missing nag

* temp bring back environment page

* remove delete version action from project page

* replace "continue" next button label with actual next step

* fix types

* pnpm run fix

* move supplementary files alert up and update border radius style on dropzone

* copy updates

* small update on version num placeholder

* update placeholder

* make timeout on upload routes 2 minutes

* fix lint issues

* run pnpm intl:extract

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2025-12-18 11:56:15 -08:00
committed by GitHub
parent 9ad01723a2
commit 9958600121
69 changed files with 4954 additions and 585 deletions

View File

@@ -124,6 +124,11 @@ export abstract class AbstractModrinthClient {
},
}
const headers = mergedOptions.headers
if (headers && 'Content-Type' in headers && headers['Content-Type'] === '') {
delete headers['Content-Type']
}
const context = this.buildContext(url, path, mergedOptions)
try {

View File

@@ -6,6 +6,7 @@ import { ArchonServersV0Module } from './archon/servers/v0'
import { ArchonServersV1Module } from './archon/servers/v1'
import { ISO3166Module } from './iso3166'
import { KyrosFilesV0Module } from './kyros/files/v0'
import { LabrinthVersionsV3Module } from './labrinth'
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthProjectsV2Module } from './labrinth/projects/v2'
@@ -35,6 +36,7 @@ export const MODULE_REGISTRY = {
labrinth_projects_v2: LabrinthProjectsV2Module,
labrinth_projects_v3: LabrinthProjectsV3Module,
labrinth_state: LabrinthStateModule,
labrinth_versions_v3: LabrinthVersionsV3Module,
} as const satisfies Record<string, ModuleConstructor>
export type ModuleID = keyof typeof MODULE_REGISTRY

View File

@@ -3,3 +3,4 @@ export * from './collections'
export * from './projects/v2'
export * from './projects/v3'
export * from './state'
export * from './versions/v3'

View File

@@ -68,7 +68,10 @@ export class LabrinthProjectsV2Module extends AbstractModule {
api: 'labrinth',
version: 2,
method: 'GET',
params: params as Record<string, unknown>,
params: {
...params,
facets: params.facets ? JSON.stringify(params.facets) : undefined,
},
})
}

View File

@@ -172,6 +172,7 @@ export namespace Labrinth {
| 'shader'
| 'plugin'
| 'datapack'
| 'project'
export type GalleryImage = {
url: string
@@ -264,7 +265,7 @@ export namespace Labrinth {
export type ProjectSearchParams = {
query?: string
facets?: string[][]
facets?: string[][] // in the format of [["categories:forge"],["versions:1.17.1"]]
filters?: string
index?: 'relevance' | 'downloads' | 'follows' | 'newest' | 'updated'
offset?: number
@@ -420,23 +421,38 @@ export namespace Labrinth {
}
}
// TODO: consolidate duplicated types between v2 and v3 versions
export namespace v3 {
export type VersionType = 'release' | 'beta' | 'alpha'
export interface Dependency {
dependency_type: Labrinth.Versions.v2.DependencyType
project_id?: string
file_name?: string
version_id?: string
}
export type VersionStatus =
| 'listed'
| 'archived'
| 'draft'
| 'unlisted'
| 'scheduled'
export interface GetProjectVersionsParams {
game_versions?: string[]
loaders?: string[]
}
export type VersionChannel = 'release' | 'beta' | 'alpha'
export type FileType =
| 'required-resource-pack'
| 'optional-resource-pack'
| 'sources-jar'
| 'dev-jar'
| 'javadoc-jar'
| 'signature'
| 'unknown'
export type DependencyType = 'required' | 'optional' | 'incompatible' | 'embedded'
export interface VersionFileHash {
sha512: string
sha1: string
}
export type FileType = 'required-resource-pack' | 'optional-resource-pack' | 'unknown'
export type VersionFile = {
hashes: Record<string, string>
interface VersionFile {
hashes: VersionFileHash
url: string
filename: string
primary: boolean
@@ -444,35 +460,75 @@ export namespace Labrinth {
file_type?: FileType
}
export type Dependency = {
version_id?: string
project_id?: string
file_name?: string
dependency_type: DependencyType
}
export type Version = {
export interface Version {
name: string
version_number: string
changelog?: string
dependencies: Dependency[]
game_versions: string[]
version_type: VersionChannel
loaders: string[]
featured: boolean
status: Labrinth.Versions.v2.VersionStatus
id: string
project_id: string
author_id: string
featured: boolean
name: string
version_number: string
project_types: string[]
games: string[]
changelog: string
date_published: string
downloads: number
version_type: VersionType
status: VersionStatus
requested_status?: VersionStatus | null
files: VersionFile[]
dependencies: Dependency[]
environment?: Labrinth.Projects.v3.Environment
}
export interface DraftVersionFile {
fileType?: FileType
file: File
}
export type DraftVersion = Omit<
Labrinth.Versions.v3.CreateVersionRequest,
'file_parts' | 'primary_file' | 'file_types'
> & {
existing_files?: VersionFile[]
version_id?: string
environment?: Labrinth.Projects.v3.Environment
}
export interface CreateVersionRequest {
name: string
version_number: string
changelog: string
dependencies?: Array<{
version_id?: string
project_id?: string
file_name?: string
dependency_type: Labrinth.Versions.v2.DependencyType
}>
game_versions: string[]
version_type: 'release' | 'beta' | 'alpha'
loaders: string[]
ordering?: number | null
game_versions?: string[]
mrpack_loaders?: string[]
environment?: string
featured?: boolean
status?: 'listed' | 'archived' | 'draft' | 'unlisted' | 'scheduled' | 'unknown'
requested_status?: 'listed' | 'archived' | 'draft' | 'unlisted' | null
project_id: string
file_parts: string[]
primary_file?: string
file_types?: Record<string, Labrinth.Versions.v3.FileType | null>
environment?: Labrinth.Projects.v3.Environment
}
export type ModifyVersionRequest = Partial<
Omit<CreateVersionRequest, 'project_id' | 'file_parts' | 'primary_file' | 'file_types'>
> & {
file_types?: {
algorithm: string
hash: string
file_type: Labrinth.Versions.v3.FileType | null
}[]
}
export type AddFilesToVersionRequest = {
file_parts: string[]
file_types?: Record<string, Labrinth.Versions.v3.FileType | null>
}
}
}
@@ -566,7 +622,7 @@ export namespace Labrinth {
export interface GameVersion {
version: string
version_type: string
date: string // RFC 3339 DateTime
date: string
major: boolean
}

View File

@@ -0,0 +1,282 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthVersionsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_versions_v3'
}
/**
* Get versions for a project (v3)
*
* @param id - Project ID or slug (e.g., 'sodium' or 'AANobbMI')
* @param options - Optional query parameters to filter versions
* @returns Promise resolving to an array of v3 versions
*
* @example
* ```typescript
* const versions = await client.labrinth.versions_v3.getProjectVersions('sodium')
* const filteredVersions = await client.labrinth.versions_v3.getProjectVersions('sodium', {
* game_versions: ['1.20.1'],
* loaders: ['fabric']
* })
* console.log(versions[0].version_number)
* ```
*/
public async getProjectVersions(
id: string,
options?: Labrinth.Versions.v3.GetProjectVersionsParams,
): Promise<Labrinth.Versions.v3.Version[]> {
const params: Record<string, string> = {}
if (options?.game_versions?.length) {
params.game_versions = JSON.stringify(options.game_versions)
}
if (options?.loaders?.length) {
params.loaders = JSON.stringify(options.loaders)
}
return this.client.request<Labrinth.Versions.v3.Version[]>(`/project/${id}/version`, {
api: 'labrinth',
version: 2, // TODO: move this to a versions v2 module to keep api-client clean and organized
method: 'GET',
params: Object.keys(params).length > 0 ? params : undefined,
})
}
/**
* Get a specific version by ID (v3)
*
* @param id - Version ID
* @returns Promise resolving to the v3 version data
*
* @example
* ```typescript
* const version = await client.labrinth.versions_v3.getVersion('DXtmvS8i')
* console.log(version.version_number)
* ```
*/
public async getVersion(id: string): Promise<Labrinth.Versions.v3.Version> {
return this.client.request<Labrinth.Versions.v3.Version>(`/version/${id}`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Get multiple versions by IDs (v3)
*
* @param ids - Array of version IDs
* @returns Promise resolving to an array of v3 versions
*
* @example
* ```typescript
* const versions = await client.labrinth.versions_v3.getVersions(['DXtmvS8i', 'abc123'])
* console.log(versions[0].version_number)
* ```
*/
public async getVersions(ids: string[]): Promise<Labrinth.Versions.v3.Version[]> {
return this.client.request<Labrinth.Versions.v3.Version[]>(`/versions`, {
api: 'labrinth',
version: 3,
method: 'GET',
params: { ids: JSON.stringify(ids) },
})
}
/**
* Get a version from a project by version ID or number (v3)
*
* @param projectId - Project ID or slug
* @param versionId - Version ID or version number
* @returns Promise resolving to the v3 version data
*
* @example
* ```typescript
* const version = await client.labrinth.versions_v3.getVersionFromIdOrNumber('sodium', 'DXtmvS8i')
* const versionByNumber = await client.labrinth.versions_v3.getVersionFromIdOrNumber('sodium', '0.4.12')
* ```
*/
public async getVersionFromIdOrNumber(
projectId: string,
versionId: string,
): Promise<Labrinth.Versions.v3.Version> {
return this.client.request<Labrinth.Versions.v3.Version>(
`/project/${projectId}/version/${versionId}`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
/**
* Create a new version for a project (v3)
*
* Creates a new version on an existing project. At least one file must be
* attached unless the version is created as a draft.
*
* @param data - JSON metadata payload for the version (must include file_parts)
* @param files - Array of uploaded files, in the same order as `data.file_parts`
*
* @returns A promise resolving to the newly created version data
*
* @example
* ```ts
* const version = await client.labrinth.versions_v3.createVersion('sodium', {
* name: 'v0.5.0',
* version_number: '0.5.0',
* version_type: 'release',
* loaders: ['fabric'],
* game_versions: ['1.20.1'],
* project_id: 'sodium',
* file_parts: ['primary']
* }, [fileObject])
* ```
*/
public async createVersion(
draftVersion: Labrinth.Versions.v3.DraftVersion,
versionFiles: Labrinth.Versions.v3.DraftVersionFile[],
): Promise<Labrinth.Versions.v3.Version> {
const formData = new FormData()
const files = versionFiles.map((vf) => vf.file)
const fileTypes = versionFiles.map((vf) => vf.fileType || null)
const fileParts = files.map((file, i) => {
return `${file.name}-${i === 0 ? 'primary' : i}`
})
const fileTypeMap = fileParts.reduce<Record<string, Labrinth.Versions.v3.FileType | null>>(
(acc, key, i) => {
acc[key] = fileTypes[i]
return acc
},
{},
)
const data: Labrinth.Versions.v3.CreateVersionRequest = {
project_id: draftVersion.project_id,
version_number: draftVersion.version_number,
name: draftVersion.name || draftVersion.version_number,
changelog: draftVersion.changelog,
dependencies: draftVersion.dependencies || [],
game_versions: draftVersion.game_versions,
loaders: draftVersion.loaders,
version_type: draftVersion.version_type,
featured: !!draftVersion.featured,
file_parts: fileParts,
file_types: fileTypeMap,
primary_file: fileParts[0],
environment: draftVersion.environment,
}
formData.append('data', JSON.stringify(data))
files.forEach((file, i) => {
formData.append(fileParts[i], new Blob([file]), file.name)
})
return this.client.request<Labrinth.Versions.v3.Version>(`/version`, {
api: 'labrinth',
version: 3,
method: 'POST',
body: formData,
timeout: 120000,
headers: {
'Content-Type': '',
},
})
}
/**
* Modify an existing version by ID (v3)
*
* Partially updates a versions metadata. Only JSON fields may be modified.
* To update files, use the separate "Add files to version" endpoint.
*
* @param versionId - The version ID to update
* @param data - PATCH metadata for this version (all fields optional)
*
* @returns A promise resolving to the updated version data
*
* @example
* ```ts
* const updated = await client.labrinth.versions_v3.modifyVersion('DXtmvS8i', {
* name: 'v1.0.1',
* changelog: 'Updated changelog',
* featured: true,
* status: 'listed'
* })
* ```
*/
public async modifyVersion(
versionId: string,
data: Labrinth.Versions.v3.ModifyVersionRequest,
): Promise<Labrinth.Versions.v3.Version> {
return this.client.request<Labrinth.Versions.v3.Version>(`/version/${versionId}`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: data,
})
}
/**
* Delete a version by ID (v3)
*
* @param versionId - Version ID
*
* @example
* ```typescript
* await client.labrinth.versions_v3.deleteVersion('DXtmvS8i')
* ```
*/
public async deleteVersion(versionId: string): Promise<void> {
return this.client.request(`/version/${versionId}`, {
api: 'labrinth',
version: 2,
method: 'DELETE',
})
}
public async addFilesToVersion(
versionId: string,
versionFiles: Labrinth.Versions.v3.DraftVersionFile[],
): Promise<Labrinth.Versions.v3.Version> {
const formData = new FormData()
const files = versionFiles.map((vf) => vf.file)
const fileTypes = versionFiles.map((vf) => vf.fileType || null)
const fileParts = files.map((file, i) => `${file.name}-${i}`)
const fileTypeMap = fileParts.reduce<Record<string, Labrinth.Versions.v3.FileType | null>>(
(acc, key, i) => {
acc[key] = fileTypes[i]
return acc
},
{},
)
formData.append('data', JSON.stringify({ file_types: fileTypeMap }))
files.forEach((file, i) => {
formData.append(fileParts[i], new Blob([file]), file.name)
})
return this.client.request<Labrinth.Versions.v3.Version>(`/version/${versionId}/file`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: formData,
timeout: 120000,
headers: {
'Content-Type': '',
},
})
}
}

View File

@@ -75,6 +75,7 @@ import _FilterXIcon from './icons/filter-x.svg?component'
import _FolderArchiveIcon from './icons/folder-archive.svg?component'
import _FolderOpenIcon from './icons/folder-open.svg?component'
import _FolderSearchIcon from './icons/folder-search.svg?component'
import _FolderUpIcon from './icons/folder-up.svg?component'
import _GameIcon from './icons/game.svg?component'
import _GapIcon from './icons/gap.svg?component'
import _GaugeIcon from './icons/gauge.svg?component'
@@ -292,6 +293,7 @@ export const FilterIcon = _FilterIcon
export const FolderArchiveIcon = _FolderArchiveIcon
export const FolderOpenIcon = _FolderOpenIcon
export const FolderSearchIcon = _FolderSearchIcon
export const FolderUpIcon = _FolderUpIcon
export const GameIcon = _GameIcon
export const GapIcon = _GapIcon
export const GaugeIcon = _GaugeIcon

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-up-icon lucide-folder-up"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="M12 10v6"/><path d="m9 13 3-3 3 3"/></svg>

After

Width:  |  Height:  |  Size: 416 B

View File

@@ -235,3 +235,23 @@ h3 {
margin-block: var(--gap-md) var(--gap-md);
color: var(--color-contrast);
}
// Scrollbar styles
::-webkit-scrollbar {
width: 0.75rem;
height: 0.75rem;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-button-bg);
}
// Firefox scrollbar
* {
scrollbar-width: thin;
scrollbar-color: var(--color-button-bg) transparent;
}

View File

@@ -39,7 +39,7 @@ export const coreNags: Nag[] = [
status: 'required',
shouldShow: (context: NagContext) => context.versions.length < 1,
link: {
path: 'versions',
path: 'settings/versions',
title: defineMessage({
id: 'nags.versions.title',
defaultMessage: 'Visit versions page',
@@ -126,7 +126,7 @@ export const coreNags: Nag[] = [
)
},
link: {
path: 'gallery',
path: 'settings/gallery',
title: defineMessage({
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
@@ -151,7 +151,7 @@ export const coreNags: Nag[] = [
return context.project?.gallery?.length === 0 || !featuredGalleryImage
},
link: {
path: 'gallery',
path: 'settings/gallery',
title: defineMessage({
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
@@ -211,46 +211,6 @@ export const coreNags: Nag[] = [
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
{
id: 'select-environments',
title: defineMessage({
id: 'nags.select-environments.title',
defaultMessage: 'Select environments',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.select-environments.description',
defaultMessage: `Select the environments your {type, select, mod {mod} modpack {modpack} other {project}} functions on.`,
}),
{
type: context.project.project_type,
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
const excludedTypes = ['resourcepack', 'plugin', 'shader', 'datapack']
return (
context.project.versions.length > 0 &&
!excludedTypes.includes(context.project.project_type) &&
(context.project.client_side === 'unknown' ||
context.project.server_side === 'unknown' ||
(context.project.client_side === 'unsupported' &&
context.project.server_side === 'unsupported'))
)
},
link: {
path: 'settings/environment',
title: defineMessage({
id: 'nags.settings.environments.title',
defaultMessage: 'Visit environment settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-environment',
},
},
{
id: 'select-license',
title: defineMessage({

View File

@@ -122,12 +122,6 @@
"nags.multiple-resolution-tags.title": {
"defaultMessage": "Select correct resolution"
},
"nags.select-environments.description": {
"defaultMessage": "Select the environments your {type, select, mod {mod} modpack {modpack} other {project}} functions on."
},
"nags.select-environments.title": {
"defaultMessage": "Select environments"
},
"nags.select-license.description": {
"defaultMessage": "Select the license your {type, select, mod {mod} modpack {modpack} resourcepack {resource pack} shader {shader} plugin {plugin} datapack {data pack} other {project}} is distributed under."
},
@@ -143,9 +137,6 @@
"nags.settings.description.title": {
"defaultMessage": "Visit description settings"
},
"nags.settings.environments.title": {
"defaultMessage": "Visit environment settings"
},
"nags.settings.license.title": {
"defaultMessage": "Visit license settings"
},

View File

@@ -29,6 +29,7 @@
"stripe": "^18.1.1",
"typescript": "^5.4.5",
"vue": "^3.5.13",
"vue-component-type-helpers": "^3.1.8",
"vue-router": "4.3.0"
},
"dependencies": {

View File

@@ -3,8 +3,12 @@
<Button
v-for="item in items"
:key="formatLabel(item)"
class="btn"
:class="{ selected: selected === item, capitalize: capitalize }"
class="btn !brightness-100 hover:!brightness-125"
:class="{
selected: selected === item,
capitalize: capitalize,
'!px-2.5 !py-1.5': size === 'small',
}"
@click="toggleItem(item)"
>
<CheckIcon v-if="selected === item" />
@@ -24,14 +28,17 @@ const props = withDefaults(
formatLabel?: (item: T) => string
neverEmpty?: boolean
capitalize?: boolean
size?: 'standard' | 'small'
}>(),
{
neverEmpty: true,
// Intentional any type, as this default should only be used for primitives (string or number)
formatLabel: (item) => item.toString(),
capitalize: true,
size: 'standard',
},
)
const selected = defineModel<T | null>()
// If one always has to be selected, default to the first one

View File

@@ -61,6 +61,7 @@
:placeholder="searchPlaceholder"
class=""
@keydown.stop="handleSearchKeydown"
@input="emit('searchInput', searchQuery)"
/>
</div>
</div>
@@ -107,7 +108,7 @@
</div>
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
No results found
{{ noOptionsMessage }}
</div>
</div>
</Teleport>
@@ -168,6 +169,7 @@ const props = withDefaults(
extraPosition?: 'top' | 'bottom'
triggerClass?: string
forceDirection?: 'up' | 'down'
noOptionsMessage?: string
}>(),
{
placeholder: 'Select an option',
@@ -178,6 +180,7 @@ const props = withDefaults(
showChevron: true,
maxHeight: DEFAULT_MAX_HEIGHT,
extraPosition: 'bottom',
noOptionsMessage: 'No results found',
},
)
@@ -186,6 +189,7 @@ const emit = defineEmits<{
select: [option: DropdownOption<T>]
open: []
close: []
searchInput: [query: string]
}>()
const slots = useSlots()

View File

@@ -0,0 +1,122 @@
<template>
<label
:class="[
'flex flex-col items-center justify-center cursor-pointer border-2 border-dashed bg-surface-4 text-contrast transition-colors',
size === 'small' ? 'p-5' : 'p-12',
size === 'small' ? 'gap-2' : 'gap-4',
size === 'small' ? 'rounded-2xl' : 'rounded-3xl',
isDragOver ? 'border-purple' : 'border-surface-5',
]"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="handleDrop"
>
<div
:class="[
'grid place-content-center text-brand border-brand border-solid border bg-highlight-green',
size === 'small' ? 'w-10 h-10' : 'h-14 w-14',
size === 'small' ? 'rounded-xl' : 'rounded-2xl',
]"
>
<FolderUpIcon
aria-hidden="true"
:class="['text-brand', size === 'small' ? 'w-6 h-6' : 'w-8 h-8']"
/>
</div>
<div class="flex flex-col items-center justify-center gap-1 text-contrast text-center">
<div class="text-contrast font-medium text-pretty">
{{ primaryPrompt }}
</div>
<span class="text-primary text-sm text-pretty">
{{ secondaryPrompt }}
</span>
</div>
<input
ref="fileInput"
type="file"
:multiple="multiple"
:accept="accept"
:disabled="disabled"
class="hidden"
@change="handleChange"
/>
</label>
</template>
<script setup lang="ts">
import { FolderUpIcon } from '@modrinth/assets'
import { fileIsValid } from '@modrinth/utils'
import { ref } from 'vue'
const fileInput = ref<HTMLInputElement | null>(null)
const emit = defineEmits<{
(e: 'change', files: File[]): void
}>()
const props = withDefaults(
defineProps<{
prompt?: string
primaryPrompt?: string | null
secondaryPrompt?: string | null
multiple?: boolean
accept?: string
maxSize?: number | null
shouldAlwaysReset?: boolean
disabled?: boolean
size?: 'small' | 'standard'
}>(),
{
prompt: 'Drag and drop files or click to browse',
primaryPrompt: 'Drag and drop files or click to browse',
secondaryPrompt: 'You can try to drag files or folder or click this area to select it',
size: 'standard',
},
)
const files = ref<File[]>([])
function addFiles(incoming: FileList, shouldNotReset = false) {
if (!shouldNotReset || props.shouldAlwaysReset) {
files.value = Array.from(incoming)
}
const validationOptions = {
maxSize: props.maxSize ?? undefined,
alertOnInvalid: true,
}
files.value = files.value.filter((file) => fileIsValid(file, validationOptions))
if (files.value.length > 0) {
emit('change', files.value)
}
if (fileInput.value) fileInput.value.value = ''
}
const isDragOver = ref(false)
function onDragOver() {
isDragOver.value = true
}
function onDragLeave() {
isDragOver.value = false
}
function handleDrop(e: DragEvent) {
isDragOver.value = false
if (!e.dataTransfer) return
addFiles(e.dataTransfer.files)
}
function handleChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
addFiles(input.files)
}
</script>

View File

@@ -107,7 +107,7 @@ label {
grid-gap: 0.5rem;
background-color: var(--color-button-bg);
border-radius: var(--radius-sm);
border: dashed 0.3rem var(--color-contrast);
border: dashed 2px var(--color-contrast);
cursor: pointer;
color: var(--color-contrast);
}

View File

@@ -1,5 +1,5 @@
<template>
<Modal ref="linkModal" header="Insert link">
<NewModal ref="linkModal" header="Insert link">
<div class="modal-insert">
<label class="label" for="insert-link-label">
<span class="label__title">Label</span>
@@ -59,8 +59,8 @@
>
</div>
</div>
</Modal>
<Modal ref="imageModal" header="Insert image">
</NewModal>
<NewModal ref="imageModal" header="Insert image">
<div class="modal-insert">
<label class="label" for="insert-image-alt">
<span class="label__title">Description (alt text)<span class="required">*</span></span>
@@ -147,8 +147,8 @@
</Button>
</div>
</div>
</Modal>
<Modal ref="videoModal" header="Insert YouTube video">
</NewModal>
<NewModal ref="videoModal" header="Insert YouTube video">
<div class="modal-insert">
<label class="label" for="insert-video-url">
<span class="label__title">YouTube video URL<span class="required">*</span></span>
@@ -201,7 +201,7 @@
</Button>
</div>
</div>
</Modal>
</NewModal>
<div class="resizable-textarea-wrapper">
<div class="editor-action-row">
<div class="editor-actions">
@@ -223,10 +223,10 @@
</Button>
</template>
</template>
</div>
<div class="preview">
<Toggle id="preview" v-model="previewMode" />
<label class="label" for="preview"> Preview </label>
<div class="preview">
<Toggle id="preview" v-model="previewMode" />
<label class="label" for="preview"> Preview </label>
</div>
</div>
</div>
<div ref="editorRef" :class="{ hide: previewMode }" />
@@ -292,11 +292,11 @@ import {
XIcon,
YouTubeIcon,
} from '@modrinth/assets'
import NewModal from '@modrinth/ui/src/components/modal/NewModal.vue'
import { markdownCommands, modrinthMarkdownEditorKeymap } from '@modrinth/utils/codemirror'
import { renderHighlightedString } from '@modrinth/utils/highlightjs'
import { type Component, computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'
import Modal from '../modal/Modal.vue'
import Button from './Button.vue'
import Chips from './Chips.vue'
import FileInput from './FileInput.vue'
@@ -756,9 +756,9 @@ const videoMarkdown = computed(() => {
return ''
})
const linkModal = ref<InstanceType<typeof Modal> | null>(null)
const imageModal = ref<InstanceType<typeof Modal> | null>(null)
const videoModal = ref<InstanceType<typeof Modal> | null>(null)
const linkModal = ref<InstanceType<typeof NewModal> | null>(null)
const imageModal = ref<InstanceType<typeof NewModal> | null>(null)
const videoModal = ref<InstanceType<typeof NewModal> | null>(null)
function resetModalStates() {
linkText.value = ''

View File

@@ -0,0 +1,227 @@
<template>
<NewModal
ref="modal"
:scrollable="true"
max-content-height="72vh"
:on-hide="onModalHide"
:closable="true"
:close-on-click-outside="false"
>
<template #title>
<div class="flex flex-wrap items-center gap-1 text-secondary">
<span class="text-lg font-bold text-contrast sm:text-xl">{{ resolvedTitle }}</span>
</div>
</template>
<progress
v-if="currentStage?.nonProgressStage !== true"
:value="progressValue"
max="100"
class="w-full h-1 appearance-none border-none absolute top-0 left-0"
></progress>
<div class="sm:w-[512px]">
<component :is="currentStage?.stageContent" />
</div>
<template #actions>
<div
class="flex flex-col justify-end gap-2 sm:flex-row"
:class="leftButtonConfig || rightButtonConfig ? 'mt-4' : ''"
>
<ButtonStyled v-if="leftButtonConfig" type="outlined">
<button
class="!border-surface-5"
:disabled="leftButtonConfig.disabled"
@click="leftButtonConfig.onClick"
>
<component :is="leftButtonConfig.icon" />
{{ leftButtonConfig.label }}
</button>
</ButtonStyled>
<ButtonStyled v-if="rightButtonConfig" :color="rightButtonConfig.color">
<button :disabled="rightButtonConfig.disabled" @click="rightButtonConfig.onClick">
<component
:is="rightButtonConfig.icon"
v-if="rightButtonConfig.iconPosition === 'before'"
:class="rightButtonConfig.iconClass"
/>
{{ rightButtonConfig.label }}
<component
:is="rightButtonConfig.icon"
v-if="rightButtonConfig.iconPosition === 'after'"
:class="rightButtonConfig.iconClass"
/>
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script lang="ts">
import { ButtonStyled, NewModal } from '@modrinth/ui'
import type { Component } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
export interface StageButtonConfig {
label?: string
icon?: Component | null
iconPosition?: 'before' | 'after'
color?: InstanceType<typeof ButtonStyled>['$props']['color']
disabled?: boolean
iconClass?: string | null
onClick?: () => void
}
export type MaybeCtxFn<T, R> = R | ((ctx: T) => R)
export interface StageConfigInput<T> {
id: string
stageContent: Component
title: MaybeCtxFn<T, string>
skip?: MaybeCtxFn<T, boolean>
nonProgressStage?: boolean
leftButtonConfig: MaybeCtxFn<T, StageButtonConfig | null>
rightButtonConfig: MaybeCtxFn<T, StageButtonConfig | null>
}
export function resolveCtxFn<T, R>(value: MaybeCtxFn<T, R>, ctx: T): R {
return typeof value === 'function' ? (value as (ctx: T) => R)(ctx) : value
}
</script>
<script setup lang="ts" generic="T">
const props = defineProps<{
stages: StageConfigInput<T>[]
context: T
}>()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const currentStageIndex = ref<number>(0)
function show() {
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
const setStage = (indexOrId: number | string) => {
let index: number = 0
if (typeof indexOrId === 'number') {
index = indexOrId
if (index < 0 || index >= props.stages.length) return
} else {
index = props.stages.findIndex((stage) => stage.id === indexOrId)
if (index === -1) return
}
while (index < props.stages.length) {
const skip = props.stages[index]?.skip
if (!skip || !resolveCtxFn(skip, props.context)) break
index++
}
if (index < props.stages.length) {
currentStageIndex.value = index
}
}
const nextStage = () => {
if (currentStageIndex.value === -1) return
if (currentStageIndex.value >= props.stages.length - 1) return
let nextIndex = currentStageIndex.value + 1
while (nextIndex < props.stages.length) {
const skip = props.stages[nextIndex]?.skip
if (!skip || !resolveCtxFn(skip, props.context)) break
nextIndex++
}
if (nextIndex < props.stages.length) {
currentStageIndex.value = nextIndex
}
}
const prevStage = () => {
if (currentStageIndex.value <= 0) return
let prevIndex = currentStageIndex.value - 1
while (prevIndex >= 0) {
const skip = props.stages[prevIndex]?.skip
if (!skip || !resolveCtxFn(skip, props.context)) break
prevIndex--
}
if (prevIndex >= 0) {
currentStageIndex.value = prevIndex
}
}
const currentStage = computed(() => props.stages[currentStageIndex.value])
const resolvedTitle = computed(() => {
const stage = currentStage.value
if (!stage) return ''
return resolveCtxFn(stage.title, props.context)
})
const leftButtonConfig = computed(() => {
const stage = currentStage.value
if (!stage) return null
return resolveCtxFn(stage.leftButtonConfig, props.context)
})
const rightButtonConfig = computed(() => {
const stage = currentStage.value
if (!stage) return null
return resolveCtxFn(stage.rightButtonConfig, props.context)
})
const progressValue = computed(() => {
const isProgressStage = (stage: StageConfigInput<T>) => {
if (stage.nonProgressStage) return false
const skip = stage.skip ? resolveCtxFn(stage.skip, props.context) : false
return !skip
}
const completedCount = props.stages
.slice(0, currentStageIndex.value + 1)
.filter(isProgressStage).length
const totalCount = props.stages.filter(isProgressStage).length
return totalCount > 0 ? (completedCount / totalCount) * 100 : 0
})
const emit = defineEmits<{
(e: 'refresh-data' | 'hide'): void
}>()
function onModalHide() {
emit('hide')
}
defineExpose({
show,
hide,
setStage,
nextStage,
prevStage,
currentStageIndex,
})
</script>
<style scoped>
progress {
@apply bg-surface-3;
background-color: var(--surface-3, rgb(30, 30, 30));
}
progress::-webkit-progress-bar {
@apply bg-surface-3;
}
progress::-webkit-progress-value {
@apply bg-contrast;
}
progress::-moz-progress-bar {
@apply bg-contrast;
}
</style>

View File

@@ -22,23 +22,30 @@
}"
class="page-number-container"
>
<div v-if="item === '-'">
<GapIcon />
<div v-if="item === '-'" class="rotate-90 grid place-content-center">
<EllipsisVerticalIcon />
</div>
<ButtonStyled
v-else
circular
:color="page === item ? 'brand' : 'standard'"
:type="page === item ? 'standard' : 'transparent'"
:type="page === item ? 'highlight' : 'transparent'"
>
<a
v-if="linkFunction"
:href="linkFunction(item)"
:class="page === item ? '!text-brand' : ''"
@click.prevent="page !== item ? switchPage(item) : null"
>
{{ item }}
</a>
<button v-else @click="page !== item ? switchPage(item) : null">{{ item }}</button>
<button
v-else
:class="page === item ? '!text-brand' : ''"
@click="page !== item ? switchPage(item) : null"
>
{{ item }}
</button>
</ButtonStyled>
</div>
@@ -58,7 +65,7 @@
</div>
</template>
<script setup lang="ts">
import { ChevronLeftIcon, ChevronRightIcon, GapIcon } from '@modrinth/assets'
import { ChevronLeftIcon, ChevronRightIcon, EllipsisVerticalIcon } from '@modrinth/assets'
import { computed } from 'vue'
import ButtonStyled from './ButtonStyled.vue'

View File

@@ -19,6 +19,7 @@ export { default as CopyCode } from './CopyCode.vue'
export { default as DoubleIcon } from './DoubleIcon.vue'
export { default as DropArea } from './DropArea.vue'
export { default as DropdownSelect } from './DropdownSelect.vue'
export { default as DropzoneFileInput } from './DropzoneFileInput.vue'
export { default as EnvironmentIndicator } from './EnvironmentIndicator.vue'
export { default as ErrorInformationCard } from './ErrorInformationCard.vue'
export { default as FileInput } from './FileInput.vue'
@@ -32,6 +33,9 @@ export { default as JoinedButtons } from './JoinedButtons.vue'
export { default as LoadingIndicator } from './LoadingIndicator.vue'
export { default as ManySelect } from './ManySelect.vue'
export { default as MarkdownEditor } from './MarkdownEditor.vue'
export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiStageModal.vue'
export { default as MultiStageModal } from './MultiStageModal.vue'
export { resolveCtxFn } from './MultiStageModal.vue'
export { default as OptionGroup } from './OptionGroup.vue'
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
export { default as OverflowMenu } from './OverflowMenu.vue'

View File

@@ -313,7 +313,7 @@ function handleKeyDown(event: KeyboardEvent) {
box-shadow: 4px 4px 26px 10px rgba(0, 0, 0, 0.08);
max-height: calc(100% - 2 * var(--gap-lg));
max-width: min(var(--_max-width, 60rem), calc(100% - 2 * var(--gap-lg)));
overflow-y: auto;
overflow-y: hidden;
overflow-x: hidden;
width: fit-content;
pointer-events: auto;

View File

@@ -1,18 +1,44 @@
<template>
<div class="mb-3 flex flex-wrap gap-2">
<VersionFilterControl
ref="versionFilters"
:versions="versions"
:game-versions="gameVersions"
:base-id="`${baseId}-filter`"
@update:query="updateQuery"
/>
<Pagination
:page="currentPage"
class="ml-auto mt-auto"
:count="Math.ceil(filteredVersions.length / pageSize)"
@switch-page="switchPage"
/>
<div class="flex flex-col gap-3 mb-3">
<div class="flex flex-wrap justify-between gap-2">
<VersionFilterControl
ref="versionFilters"
:versions="versions"
:game-versions="gameVersions"
:base-id="`${baseId}-filter`"
@update:query="updateQuery"
/>
<ButtonStyled v-if="openModal" color="green">
<button @click="openModal"><PlusIcon /> Create version</button>
</ButtonStyled>
<Pagination
v-if="!openModal"
:page="currentPage"
class="mt-auto"
:count="Math.ceil(filteredVersions.length / pageSize)"
@switch-page="switchPage"
/>
</div>
<div
v-if="openModal && filteredVersions.length > pageSize"
class="flex flex-wrap justify-between items-center gap-2"
>
<span>
Showing {{ (currentPage - 1) * pageSize + 1 }} to
{{ Math.min(currentPage * pageSize, filteredVersions.length) }} of
{{ filteredVersions.length }}
</span>
<Pagination
:page="currentPage"
class="mt-auto"
:count="Math.ceil(filteredVersions.length / pageSize)"
@switch-page="switchPage"
/>
</div>
</div>
<div
v-if="versions.length > 0"
@@ -169,14 +195,15 @@
</div>
</template>
<script setup lang="ts">
import { CalendarIcon, DownloadIcon, StarIcon } from '@modrinth/assets'
import type { Labrinth } from '@modrinth/api-client'
import { CalendarIcon, DownloadIcon, PlusIcon, StarIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import {
formatBytes,
formatCategory,
formatNumber,
formatVersionsForDisplay,
type GameVersionTag,
type PlatformTag,
type Version,
} from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
@@ -207,9 +234,10 @@ const props = withDefaults(
versions: VersionWithDisplayUrlEnding[]
showFiles?: boolean
currentMember?: boolean
loaders: PlatformTag[]
loaders: Labrinth.Tags.v2.Loader[]
gameVersions: GameVersionTag[]
versionLink?: (version: Version) => string
openModal?: () => void
}>(),
{
baseId: undefined,

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { computed, ref, watch } from 'vue'
@@ -7,7 +8,7 @@ import LargeRadioButton from '../../../base/LargeRadioButton.vue'
const { formatMessage } = useVIntl()
const value = defineModel<string | undefined>({ required: true })
const value = defineModel<Labrinth.Projects.v3.Environment | undefined>({ required: true })
withDefaults(
defineProps<{
@@ -134,7 +135,7 @@ type SubOptionKey = ValidKeys<(typeof OUTER_OPTIONS)[keyof typeof OUTER_OPTIONS]
const currentOuterOption = ref<OuterOptionKey>()
const currentSubOption = ref<SubOptionKey>()
const computedOption = computed<string>(() => {
const computedOption = computed<Labrinth.Projects.v3.Environment>(() => {
switch (currentOuterOption.value) {
case 'client':
return 'client_only'
@@ -169,7 +170,7 @@ const computedOption = computed<string>(() => {
}
})
function loadEnvironmentValues(env?: EnvironmentV3) {
function loadEnvironmentValues(env?: Labrinth.Projects.v3.Environment) {
switch (env) {
case 'client_and_server':
currentOuterOption.value = 'client_and_server'

View File

@@ -9,6 +9,7 @@ export interface ProjectPageContext {
projectV2: Ref<Labrinth.Projects.v2.Project>
projectV3: Ref<Labrinth.Projects.v3.Project>
refreshProject: () => Promise<void>
refreshVersions: () => Promise<void>
currentMember: Ref<TeamMember>
}

View File

@@ -0,0 +1,267 @@
module.exports = {
content: [
'./src/components/**/*.{js,vue,ts}',
'./src/layouts/**/*.vue',
'./src/pages/**/*.vue',
'./src/plugins/**/*.{js,ts}',
'./src/app.vue',
'./src/error.vue',
// monorepo - TODO: migrate this to its own package
'../../packages/**/*.{js,vue,ts}',
],
theme: {
extend: {
colors: {
surface: {
1: 'var(--surface-1)',
2: 'var(--surface-2)',
3: 'var(--surface-3)',
4: 'var(--surface-4)',
5: 'var(--surface-5)',
},
/// TODO: Clean up these aliases within codebase to use default, primary, tertiary.
// text-default
primary: 'var(--color-text-default)',
// text-primary
contrast: 'var(--color-text-primary)',
// text-tertiary
secondary: 'var(--color-text-tertiary)',
red: {
DEFAULT: 'var(--color-red)',
50: 'var(--color-red-50)',
100: 'var(--color-red-100)',
200: 'var(--color-red-200)',
300: 'var(--color-red-300)',
400: 'var(--color-red-400)',
500: 'var(--color-red-500)',
600: 'var(--color-red-600)',
700: 'var(--color-red-700)',
800: 'var(--color-red-800)',
900: 'var(--color-red-900)',
950: 'var(--color-red-950)',
},
orange: {
DEFAULT: 'var(--color-orange)',
50: 'var(--color-orange-50)',
100: 'var(--color-orange-100)',
200: 'var(--color-orange-200)',
300: 'var(--color-orange-300)',
400: 'var(--color-orange-400)',
500: 'var(--color-orange-500)',
600: 'var(--color-orange-600)',
700: 'var(--color-orange-700)',
800: 'var(--color-orange-800)',
900: 'var(--color-orange-900)',
950: 'var(--color-orange-950)',
},
green: {
DEFAULT: 'var(--color-green)',
50: 'var(--color-green-50)',
100: 'var(--color-green-100)',
200: 'var(--color-green-200)',
300: 'var(--color-green-300)',
400: 'var(--color-green-400)',
500: 'var(--color-green-500)',
600: 'var(--color-green-600)',
700: 'var(--color-green-700)',
800: 'var(--color-green-800)',
900: 'var(--color-green-900)',
950: 'var(--color-green-950)',
},
blue: {
DEFAULT: 'var(--color-blue)',
50: 'var(--color-blue-50)',
100: 'var(--color-blue-100)',
200: 'var(--color-blue-200)',
300: 'var(--color-blue-300)',
400: 'var(--color-blue-400)',
500: 'var(--color-blue-500)',
600: 'var(--color-blue-600)',
700: 'var(--color-blue-700)',
800: 'var(--color-blue-800)',
900: 'var(--color-blue-900)',
950: 'var(--color-blue-950)',
},
purple: {
DEFAULT: 'var(--color-purple)',
50: 'var(--color-purple-50)',
100: 'var(--color-purple-100)',
200: 'var(--color-purple-200)',
300: 'var(--color-purple-300)',
400: 'var(--color-purple-400)',
500: 'var(--color-purple-500)',
600: 'var(--color-purple-600)',
700: 'var(--color-purple-700)',
800: 'var(--color-purple-800)',
900: 'var(--color-purple-900)',
950: 'var(--color-purple-950)',
},
gray: {
DEFAULT: 'var(--color-gray)',
50: 'var(--color-gray-50)',
100: 'var(--color-gray-100)',
200: 'var(--color-gray-200)',
300: 'var(--color-gray-300)',
400: 'var(--color-gray-400)',
500: 'var(--color-gray-500)',
600: 'var(--color-gray-600)',
700: 'var(--color-gray-700)',
800: 'var(--color-gray-800)',
900: 'var(--color-gray-900)',
950: 'var(--color-gray-950)',
},
/// === LEGACY ===
icon: 'var(--color-base)',
// Text
inactive: 'var(--color-text-inactive)',
dark: 'var(--color-text-dark)',
inverted: 'var(--color-text-inverted)',
heading: 'var(--color-heading)',
bg: {
DEFAULT: 'var(--surface-1)', // var(--color-bg)
red: 'var(--color-red-bg)',
orange: 'var(--color-orange-bg)',
green: 'var(--color-green-bg)',
blue: 'var(--color-blue-bg)',
purple: 'var(--color-purple-bg)',
raised: 'var(--surface-3)', // var(--color-raised-bg)
},
banners: {
error: {
bg: 'var(--banner-error-bg)',
text: 'var(--banner-error-text)',
border: 'var(--banner-error-border)',
},
warning: {
bg: 'var(--banner-warning-bg)',
text: 'var(--banner-warning-text)',
border: 'var(--banner-warning-border)',
},
info: {
bg: 'var(--banner-info-bg)',
text: 'var(--banner-info-text)',
border: 'var(--banner-info-border)',
},
},
highlight: {
DEFAULT: 'var(--color-brand-highlight)',
red: 'var(--color-red-highlight)',
orange: 'var(--color-orange-highlight)',
green: 'var(--color-green-highlight)',
blue: 'var(--color-blue-highlight)',
purple: 'var(--color-purple-highlight)',
},
divider: {
DEFAULT: 'var(--color-divider)',
dark: 'var(--color-divider-dark)',
},
brand: {
DEFAULT: 'var(--color-brand)',
red: 'var(--color-red)',
orange: 'var(--color-orange)',
green: 'var(--color-green)',
blue: 'var(--color-blue)',
purple: 'var(--color-purple)',
highlight: 'var(--color-brand-highlight)',
shadow: 'var(--color-brand-shadow)',
inverted: 'var(--color-accent-contrast)',
},
tabUnderlineHovered: 'var(--tab-underline-hovered)',
button: {
bg: 'var(--color-button-bg)',
text: 'var(--color-button-text)',
bgHover: 'var(--color-button-bg-hover)',
textHover: 'var(--color-button-text-hover)',
bgActive: 'var(--color-button-bg-active)',
textActive: 'var(--color-button-text-active)',
border: 'var(--color-button-border)',
bgSelected: 'var(--color-button-bg-selected)',
textSelected: 'var(--color-button-text-selected)',
},
toggleHandle: 'var(--color-toggle-handle)',
dropdown: {
bg: 'var(--color-dropdown-bg)',
text: 'var(--color-dropdown-text)',
},
tooltip: {
bg: 'var(--color-tooltip-bg)',
text: 'var(--color-tooltip-text)',
},
code: {
bg: 'var(--color-code-bg)',
text: 'var(--color-code-text)',
},
kbdShadow: 'var(--color-kbd-shadow)',
ad: {
DEFAULT: 'var(--color-ad)',
raised: 'var(--color-ad-raised)',
contrast: 'var(--color-ad-contrast)',
highlight: 'var(--color-ad-highlight)',
},
greyLink: {
DEFAULT: 'var(--color-grey-link)',
hover: 'var(--color-grey-link-hover)',
active: 'var(--color-grey-link-active)',
},
link: {
DEFAULT: 'var(--color-link)',
hover: 'var(--color-link-hover)',
active: 'var(--color-link-active)',
},
warning: {
bg: 'var(--color-warning-bg)',
text: 'var(--color-warning-text)',
banner: {
text: 'var(--color-warning-banner-text)',
bg: 'var(--color-warning-banner-bg)',
side: 'var(--color-warning-banner-side)',
},
},
infoBanner: {
text: 'var(--color-info-banner-text)',
bg: 'var(--color-info-banner-bg)',
side: 'var(--color-info-banner-side)',
},
blockQuote: 'var(--color-block-quote)',
headerUnderline: 'var(--color-header-underline)',
hr: 'var(--color-hr)',
table: {
border: 'var(--color-table-border)',
alternateRow: ' var(--color-table-alternate-row)',
},
},
backgroundImage: {
mazeBg: 'var(--landing-maze-bg)',
mazeGradientBg: 'var(--landing-maze-gradient-bg)',
// @ts-ignore
landing: {
mazeOuterBg: 'var(--landing-maze-outer-bg)',
colorHeading: 'var(--landing-color-heading)',
colorSubheading: 'var(--landing-color-subheading)',
transitionGradientStart: 'var(--landing-transition-gradient-start)',
transitionGradientEnd: 'var(--landing-transition-gradient-end)',
hoverCardGradient: 'var(--landing-hover-card-gradient)',
borderGradient: 'var(--landing-border-gradient)',
borderColor: 'var(--landing-border-color)',
creatorGradient: 'var(--landing-creator-gradient)',
blobGradient: 'var(--landing-blob-gradient)',
cardBg: 'var(--landing-card-bg)',
blueLabel: 'var(--landing-blue-label)',
blueLabelBg: 'var(--landing-blue-label-bg)',
greenLabel: 'var(--landing-green-label)',
greenLabelBg: 'var(--landing-green-label-bg)',
rawBg: 'var(--landing-raw-bg)',
},
},
},
},
plugins: [],
corePlugins: {
preflight: false,
},
}

View File

@@ -0,0 +1,5 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./tailwind-preset.js'],
presets: [require('./tailwind-preset.js')],
}

View File

@@ -283,7 +283,14 @@ export interface FileDependency {
export type Dependency = VersionDependency | ProjectDependency | FileDependency
export type VersionChannel = 'release' | 'beta' | 'alpha'
export type VersionStatus = 'listed' | 'archived' | 'draft' | 'unlisted' | 'scheduled' | 'unknown'
export type FileType = 'required-resource-pack' | 'optional-resource-pack'
export type FileType =
| 'required-resource-pack'
| 'optional-resource-pack'
| 'sources-jar'
| 'dev-jar'
| 'javadoc-jar'
| 'signature'
| 'unknown'
export interface VersionFileHash {
sha512: string
@@ -291,7 +298,7 @@ export interface VersionFileHash {
}
export interface VersionFile {
hashes: VersionFileHash[]
hashes: VersionFileHash
url: string
filename: string
primary: boolean

View File

@@ -305,21 +305,23 @@ export const fileIsValid = (file, validationOptions) => {
}
export const acceptFileFromProjectType = (projectType) => {
const commonTypes = '.sig,.asc,.gpg,application/pgp-signature,application/pgp-keys'
switch (projectType) {
case 'mod':
return '.jar,.zip,.litemod,application/java-archive,application/x-java-archive,application/zip'
return `.jar,.zip,.litemod,application/java-archive,application/x-java-archive,application/zip,${commonTypes}`
case 'plugin':
return '.jar,.zip,application/java-archive,application/x-java-archive,application/zip'
return `.jar,.zip,application/java-archive,application/x-java-archive,application/zip,${commonTypes}`
case 'resourcepack':
return '.zip,application/zip'
return `.zip,application/zip,${commonTypes}`
case 'shader':
return '.zip,application/zip'
return `.zip,application/zip,${commonTypes}`
case 'datapack':
return '.zip,application/zip'
return `.zip,application/zip,${commonTypes}`
case 'modpack':
return '.mrpack,application/x-modrinth-modpack+zip,application/zip'
return `.mrpack,application/x-modrinth-modpack+zip,application/zip,${commonTypes}`
default:
return '*'
// all of the above
return `.jar,.zip,.litemod,.mrpack,application/java-archive,application/x-java-archive,application/zip,application/x-modrinth-modpack+zip,${commonTypes}`
}
}