Compare commits

...
15 changed files with 269 additions and 115 deletions
+6 -2
View File
@@ -72,6 +72,7 @@ import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import UpdateToast from '@/components/ui/UpdateToast.vue'
import UpdateAvailableToast from '@/components/ui/UpdateAvailableToast.vue'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
@@ -143,6 +144,7 @@ const showOnboarding = ref(false)
const nativeDecorations = ref(false)
const os = ref('')
const isDevEnvironment = ref(false)
const stateInitialized = ref(false)
@@ -247,6 +249,7 @@ async function setupApp() {
os.value = await getOS()
const dev = await isDev()
isDevEnvironment.value = dev
const version = await getVersion()
showOnboarding.value = !onboarded
@@ -513,13 +516,13 @@ async function checkUpdates() {
async function performCheck() {
const update = await invoke('plugin:updater|check')
const isExistingUpdate = update.version === availableUpdate.value?.version
if (!update) {
console.log('No update available')
return
}
const isExistingUpdate = update.version === availableUpdate.value?.version
if (isExistingUpdate) {
console.log('Update is already known')
return
@@ -769,6 +772,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@restart="installUpdate"
@download="downloadAvailableUpdate"
/>
<UpdateAvailableToast v-else-if="os === 'Linux' && !isDevEnvironment" />
</Transition>
</Suspense>
<Transition name="fade">
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { ExternalIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { onMounted, onUnmounted, ref } from 'vue'
const { formatMessage } = useVIntl()
const dismissed = ref(false)
const availableUpdate = ref<{ version: string } | null>(null)
let checkInterval: ReturnType<typeof setInterval> | null = null
async function checkForUpdate() {
try {
const [response, currentVersion] = await Promise.all([
fetch('https://launcher-files.modrinth.com/updates.json'),
getVersion(),
])
const updates = await response.json()
const latestVersion = updates?.version
if (latestVersion && latestVersion !== currentVersion) {
if (latestVersion !== availableUpdate.value?.version) {
availableUpdate.value = { version: latestVersion }
dismissed.value = false
}
}
} catch (e) {
console.error('Failed to check for updates:', e)
}
}
function dismiss() {
dismissed.value = true
}
onMounted(() => {
checkForUpdate()
checkInterval = setInterval(checkForUpdate, 5 * 60 * 1000)
})
onUnmounted(() => {
if (checkInterval) {
clearInterval(checkInterval)
}
})
const messages = defineMessages({
title: {
id: 'app.update-toast.title',
defaultMessage: 'Update available',
},
body: {
id: 'app.update-toast.body.linux',
defaultMessage:
'Modrinth App v{version} is now available! Update using your system package manager, or download the latest version from our website.',
},
download: {
id: 'app.update-toast.download-page',
defaultMessage: 'Download',
},
changelog: {
id: 'app.update-toast.changelog',
defaultMessage: 'Changelog',
},
})
</script>
<template>
<div
v-if="availableUpdate && !dismissed"
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-divider border-solid border-[2px]"
>
<div class="flex min-w-[25rem] gap-4">
<h2 class="whitespace-nowrap text-base text-contrast font-semibold m-0 grow">
{{ formatMessage(messages.title) }}
</h2>
<ButtonStyled size="small" circular>
<button v-tooltip="formatMessage(commonMessages.closeButton)" @click="dismiss">
<XIcon />
</button>
</ButtonStyled>
</div>
<p class="text-sm mt-2 mb-0">
{{ formatMessage(messages.body, { version: availableUpdate.version }) }}
</p>
<div class="flex gap-2 mt-4">
<ButtonStyled>
<a href="https://modrinth.com/news/changelog?filter=app">
{{ formatMessage(messages.changelog) }} <ExternalIcon />
</a>
</ButtonStyled>
</div>
</div>
</template>
@@ -63,6 +63,7 @@ async function openEditVersionModal(versionId: string, projectId: string, stageI
existing_files: versionData.files ?? [],
environment: versionData.environment,
mrpack_loaders: versionData.mrpack_loaders,
status: versionData.status,
}
openCreateVersionModal(draftVersionData, stageId)
@@ -40,7 +40,27 @@
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version changlog </span>
<span class="font-semibold text-contrast"> Status </span>
<Combobox
v-model="draftVersion.status"
:options="[
{
value: 'listed',
label: 'Listed',
subLabel: 'Visible on project page and in discovery.',
},
{
value: 'unlisted',
label: 'Unlisted',
subLabel: 'Hidden on project page and discovery, but accessible by URL.',
},
]"
:disabled="isUploading"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version changelog </span>
<div class="w-full">
<MarkdownEditor
@@ -55,7 +75,7 @@
</template>
<script lang="ts" setup>
import { Chips, MarkdownEditor } from '@modrinth/ui'
import { Chips, Combobox, MarkdownEditor } from '@modrinth/ui'
import { useImageUpload } from '~/composables/image-upload.ts'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
@@ -195,35 +195,6 @@
</div>
</div>
</div>
<Admonition v-if="!hideGalleryAdmonition && currentMember" type="info" class="mb-4">
Creating and editing gallery images can now be done directly from the
<NuxtLink to="settings/gallery" class="font-medium text-blue hover:underline"
>project settings</NuxtLink
>.
<template #actions>
<div class="flex gap-2">
<ButtonStyled color="blue">
<button
aria-label="Project Settings"
class="!shadow-none"
@click="() => $router.push('settings/gallery')"
>
<SettingsIcon />
Edit gallery
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
aria-label="Dismiss"
class="!shadow-none"
@click="() => (hideGalleryAdmonition = true)"
>
Dismiss
</button>
</ButtonStyled>
</div>
</template>
</Admonition>
<div v-if="currentMember && project.gallery.length" class="card header-buttons">
<FileInput
:max-size="5242880"
@@ -327,7 +298,6 @@ import {
PlusIcon,
RightArrowIcon,
SaveIcon,
SettingsIcon,
StarIcon,
TransferIcon,
TrashIcon,
@@ -335,15 +305,12 @@ import {
XIcon,
} from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
ConfirmModal,
DropArea,
FileInput,
injectNotificationManager,
NewModal as Modal,
} from '@modrinth/ui'
import { useLocalStorage } from '@vueuse/core'
import { isPermission } from '~/utils/permissions.ts'
@@ -376,11 +343,6 @@ useSeoMeta({
ogTitle: title,
ogDescription: description,
})
const hideGalleryAdmonition = useLocalStorage(
'hideGalleryHasMovedAdmonition',
!props.project.gallery.length,
)
</script>
<script>
@@ -59,6 +59,16 @@
</div>
</div>
</Modal>
<Admonition
v-if="version.withheldForReview"
type="warning"
class="mb-4 grid"
header="Version held for review"
body="This project version is currently held for human review and is not publicly listed. We'll update the moderation thread and apply the intended visibility once the review is complete."
style="grid-area: banner"
/>
<div class="version-page__title universal-card">
<Breadcrumbs
:current-title="version.name"
@@ -664,6 +674,7 @@ import {
XIcon,
} from '@modrinth/assets'
import {
Admonition,
Avatar,
Badge,
ButtonStyled,
@@ -714,6 +725,7 @@ export default defineNuxtComponent({
Avatar,
Badge,
Breadcrumbs,
Admonition,
CopyCode,
Multiselect,
BoxIcon,
@@ -1367,6 +1379,7 @@ export default defineNuxtComponent({
display: grid;
grid-template:
'banner' auto
'title' auto
'changelog' auto
'dependencies' auto
@@ -1376,6 +1389,7 @@ export default defineNuxtComponent({
@media (min-width: 1200px) {
grid-template:
'banner banner' auto
'title title' auto
'changelog metadata' auto
'dependencies metadata' auto
@@ -15,35 +15,13 @@
@proceed="deleteVersion()"
/>
<Admonition v-if="!hideVersionsAdmonition && currentMember" type="info" class="mb-4">
Creating and editing project versions can now be done directly from the
<NuxtLink to="settings/versions" class="font-medium text-blue hover:underline"
>project settings</NuxtLink
>.
<template #actions>
<div class="flex gap-2">
<ButtonStyled color="blue">
<button
aria-label="Project Settings"
class="!shadow-none"
@click="() => router.push('settings/versions')"
>
<SettingsIcon />
Edit versions
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
aria-label="Dismiss"
class="!shadow-none"
@click="() => (hideVersionsAdmonition = true)"
>
Dismiss
</button>
</ButtonStyled>
</div>
</template>
</Admonition>
<Admonition
v-if="heldForReviewVersionNames.length > 0"
type="warning"
class="mb-4"
:header="`${heldForReviewVersionNames.join(', ')} ${heldForReviewVersionNames.length > 1 ? 'are' : 'is'} held for review`"
body="This project version is currently held for human review and is not publicly listed. We'll update the moderation thread and apply the intended visibility once the review is complete."
/>
<ProjectPageVersions
v-if="versions.length"
@@ -271,7 +249,6 @@ import {
LinkIcon,
MoreVerticalIcon,
ReportIcon,
SettingsIcon,
ShareIcon,
TrashIcon,
} from '@modrinth/assets'
@@ -285,7 +262,6 @@ import {
OverflowMenu,
ProjectPageVersions,
} from '@modrinth/ui'
import { useLocalStorage } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
@@ -334,14 +310,11 @@ const handleOpenEditVersionModal = (versionId, projectId, stageId) => {
createProjectVersionModal.value?.openEditVersionModal(versionId, projectId, stageId)
}
const hideVersionsAdmonition = useLocalStorage(
'hideVersionsHasMovedAdmonition',
!props.versions.length,
)
const emit = defineEmits(['onDownload', 'deleteVersion'])
const router = useNativeRouter()
const heldForReviewVersionNames = computed(() =>
props.versions.filter((version) => version.withheldForReview).map((version) => version.name),
)
const baseDropdownId = useId()
@@ -38,7 +38,7 @@ const EMPTY_DRAFT_VERSION: Labrinth.Versions.v3.DraftVersion = {
loaders: [],
game_versions: [],
featured: false,
status: 'draft',
status: 'listed',
changelog: '',
dependencies: [],
}
@@ -699,6 +699,7 @@ export function createManageVersionContext(
hash: file.hashes.sha1,
file_type: file.file_type ?? null,
})),
status: version.status,
}
await labrinth.versions_v3.modifyVersion(version.version_id, data)
@@ -456,10 +456,10 @@ export namespace Labrinth {
dependencies: Dependency[]
game_versions: string[]
loaders: string[]
withheldForReview?: boolean
}
}
// TODO: consolidate duplicated types between v2 and v3 versions
export namespace v3 {
export interface Dependency {
dependency_type: Labrinth.Versions.v2.DependencyType
@@ -516,6 +516,7 @@ export namespace Labrinth {
files: VersionFile[]
environment?: Labrinth.Projects.v3.Environment
mrpack_loaders?: string[]
withheldForReview?: boolean
}
export interface DraftVersionFile {
@@ -173,6 +173,7 @@ export class LabrinthVersionsV3Module extends AbstractModule {
primary_file: fileParts[0],
environment: draftVersion.environment,
loaders: draftVersion.loaders,
status: draftVersion.status,
}
if (projectType === 'modpack') {
+32 -17
View File
@@ -6,17 +6,15 @@ import { withThemeByClassName } from '@storybook/addon-themes'
import type { Preview } from '@storybook/vue3-vite'
import { setup } from '@storybook/vue3-vite'
import FloatingVue from 'floating-vue'
import IntlMessageFormat from 'intl-messageformat'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import NotificationPanel from '../src/components/nav/NotificationPanel.vue'
import {
buildLocaleMessages,
createMessageCompiler,
type CrowdinMessages,
} from '../src/composables/i18n'
import { buildLocaleMessages, type CrowdinMessages } from '../src/composables/i18n'
import {
AbstractWebNotificationManager,
I18N_INJECTION_KEY,
type I18nContext,
type NotificationPanelLocation,
provideNotificationManager,
type WebNotification,
@@ -28,16 +26,33 @@ const localeModules = import.meta.glob('../src/locales/*/index.json', {
eager: true,
}) as Record<string, { default: CrowdinMessages }>
// Set up vue-i18n for Storybook - provides useVIntl() context for components
const i18n = createI18n({
legacy: false,
locale: 'en-US',
fallbackLocale: 'en-US',
messageCompiler: createMessageCompiler(),
missingWarn: false,
fallbackWarn: false,
messages: buildLocaleMessages(localeModules),
})
const messages = buildLocaleMessages(localeModules)
const currentLocale = ref('en-US')
// Create a simple t function that uses IntlMessageFormat for ICU syntax support
function createTranslateFunction(locale: string, msgs: Record<string, Record<string, string>>) {
return (key: string, values?: Record<string, unknown>): string => {
const localeMessages = msgs[locale] ?? msgs['en-US'] ?? {}
const message = localeMessages[key]
if (!message) return key
try {
const formatter = new IntlMessageFormat(message, locale)
return formatter.format(values ?? {}) as string
} catch {
return message
}
}
}
// Set up i18n context for Storybook - provides useVIntl() context for components
const i18nContext: I18nContext = {
locale: currentLocale,
t: (key, values) => createTranslateFunction(currentLocale.value, messages)(key, values),
setLocale: (newLocale) => {
currentLocale.value = newLocale
},
}
class StorybookNotificationManager extends AbstractWebNotificationManager {
private readonly state = ref<WebNotification[]>([])
@@ -76,7 +91,7 @@ class StorybookNotificationManager extends AbstractWebNotificationManager {
}
setup((app) => {
app.use(i18n)
app.provide(I18N_INJECTION_KEY, i18nContext)
app.use(FloatingVue, {
themes: {
'ribbit-popout': {
+16 -6
View File
@@ -95,12 +95,21 @@
<slot :name="`option-${item.value}`" :item="item">
<div class="flex items-center gap-2">
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
<span
class="font-semibold leading-tight"
:class="item.value === modelValue ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
<div class="flex flex-col gap-1.5">
<span
class="font-semibold leading-tight"
:class="item.value === modelValue ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
<span
v-if="item.subLabel"
class="text-sm"
:class="item.value === modelValue ? 'text-contrast' : 'text-secondary'"
>
{{ item.subLabel }}
</span>
</div>
</div>
</slot>
</component>
@@ -132,6 +141,7 @@ import {
export interface ComboboxOption<T> {
value: T
label: string
subLabel?: string
icon?: Component
disabled?: boolean
class?: string
@@ -109,14 +109,31 @@
/>
</div>
</div>
<div
class="pointer-events-none relative z-[1] flex flex-col justify-center"
:class="{
'group-hover:underline': !!versionLink,
}"
>
<div class="font-bold text-contrast">{{ version.version_number }}</div>
<div class="text-xs font-medium">{{ version.name }}</div>
<div class="flex gap-2 items-center flex-wrap content-center">
<div
class="pointer-events-none relative z-[1] flex flex-col justify-center"
:class="{
'group-hover:underline': !!versionLink,
}"
>
<div class="font-bold text-contrast">{{ version.version_number }}</div>
<div class="text-xs font-medium">{{ version.name }}</div>
</div>
<TagItem
v-if="version.withheldForReview"
class="border !border-solid border-orange !font-medium h-max"
style="--_bg-color: var(--color-orange-highlight); --_color: var(--color-orange)"
>
<InfoIcon />
Withheld
</TagItem>
<TagItem
v-if="version.status === 'unlisted' && currentMember"
class="border !border-solid border-surface-5 !font-medium"
style="--_bg-color: var(--surface-4); --_color: var(--color-text-default)"
>
Unlisted
</TagItem>
</div>
</div>
<div class="flex flex-col justify-center gap-2 sm:contents">
@@ -181,7 +198,7 @@
class="z-[1] flex cursor-help items-center gap-1 text-nowrap font-medium xl:self-center"
>
<CalendarIcon class="xl:hidden" />
{{ formatRelativeTime(version.date_published) }}
{{ formatRelativeTime(new Date(version.date_published)) }}
</div>
<div
class="pointer-events-none z-[1] flex items-center gap-1 font-medium xl:self-center"
@@ -222,7 +239,7 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { CalendarIcon, DownloadIcon, PlusIcon, StarIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { ButtonStyled, TagItem } from '@modrinth/ui'
import {
formatBytes,
formatCategory,
@@ -234,11 +251,11 @@ import {
import { computed, type Ref, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { InfoIcon } from '@modrinth/assets'
import { useRelativeTime } from '../../composables'
import { useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages'
import AutoLink from '../base/AutoLink.vue'
import TagItem from '../base/TagItem.vue'
import { Pagination, VersionChannelIndicator, VersionFilterControl } from '../index'
import { getEnvironmentTags } from './settings/environment/environments'
@@ -44,3 +44,27 @@ export const Disabled: Story = {
disabled: true,
},
}
export const WithSubLabels: Story = {
args: {
modelValue: '2',
options: [
{ value: '1', label: 'Fabric', subLabel: 'Lightweight modding toolchain' },
{ value: '2', label: 'Forge', subLabel: 'The original Minecraft modding API' },
{ value: '3', label: 'NeoForge', subLabel: 'Community-driven Forge fork' },
{ value: '4', label: 'Quilt', subLabel: 'The mod-loader that cares' },
],
},
}
export const MixedSubLabels: Story = {
args: {
options: [
{ value: '1', label: 'Minecraft', subLabel: 'The base game' },
{ value: '2', label: 'Fabric' },
{ value: '3', label: 'Forge', subLabel: 'Supports most mods' },
{ value: '4', label: 'NeoForge' },
{ value: '5', label: 'Quilt', subLabel: 'Fabric-compatible' },
],
},
}
@@ -40,3 +40,19 @@ export const MultipleTags: Story = {
`,
}),
}
export const ColorVariants: Story = {
render: () => ({
components: { TagItem },
template: /*html*/ `
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
<TagItem class="border !border-solid border-brand !font-medium" style="--_bg-color: var(--color-brand-highlight); --_color: var(--color-text-primary);">Brand</TagItem>
<TagItem class="border !border-solid border-red !font-medium" style="--_bg-color: var(--color-red-highlight); --_color: var(--color-text-primary);">Red</TagItem>
<TagItem class="border !border-solid border-orange !font-medium" style="--_bg-color: var(--color-orange-highlight); --_color: var(--color-text-primary);">Orange</TagItem>
<TagItem class="border !border-solid border-blue !font-medium" style="--_bg-color: var(--color-blue-highlight); --_color: var(--color-text-primary);">Blue</TagItem>
<TagItem class="border !border-solid border-purple !font-medium" style="--_bg-color: var(--color-purple-highlight); --_color: var(--color-text-primary);">Purple</TagItem>
<TagItem class="border !border-solid border-gray !font-medium" style="--_bg-color: var(--color-gray-highlight); --_color: var(--color-text-primary);">Gray</TagItem>
</div>
`,
}),
}