feat: add slug suggestions

This commit is contained in:
tdgao
2026-08-25 22:15:56 -06:00
parent 68f50cb321
commit b9a2e95da9
7 changed files with 280 additions and 8 deletions
@@ -0,0 +1,63 @@
<template>
<Transition name="slug-suggestions">
<div v-if="visible && hasSuggestions" class="mt-2 grid grid-rows-[1fr]">
<div class="flex min-h-0 flex-wrap items-center gap-2 overflow-hidden">
<span class="text-sm text-secondary">{{ formatMessage(messages.label) }}</span>
<TagItem
v-for="suggestion in suggestions"
:key="suggestion"
:action="() => emit('select', suggestion)"
@mousedown.prevent
>
<CheckIcon v-if="suggestion === selected" aria-hidden="true" />
{{ suggestion }}
</TagItem>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { CheckIcon } from '@modrinth/assets'
import { defineMessages, TagItem, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
const props = defineProps<{
selected: string
suggestions: string[]
visible: boolean
}>()
const emit = defineEmits<{
select: [suggestion: string]
}>()
const hasSuggestions = computed(() =>
props.suggestions.some((suggestion) => suggestion !== props.selected),
)
const { formatMessage } = useVIntl()
const messages = defineMessages({
label: {
id: 'project.slug-suggestions.label',
defaultMessage: 'Suggestions:',
},
})
</script>
<style scoped>
.slug-suggestions-enter-active,
.slug-suggestions-leave-active {
transition:
grid-template-rows 150ms ease,
opacity 150ms ease,
transform 150ms ease;
}
.slug-suggestions-enter-from,
.slug-suggestions-leave-to {
grid-template-rows: 0fr;
opacity: 0;
transform: translateY(-0.25rem);
}
</style>
@@ -43,10 +43,14 @@
/>
<ValidationMessage :check="nameValidation" />
</div>
<label for="slug" class="flex flex-col gap-2.5">
<span class="text-md font-semibold text-contrast">
<div
class="flex flex-col gap-2.5"
@focusin="onSlugSuggestionFocusIn"
@focusout="onSlugSuggestionFocusOut"
>
<label for="slug" class="text-md font-semibold text-contrast">
{{ formatMessage(messages.urlLabel) }}
</span>
</label>
<Input
id="slug"
v-model="slug"
@@ -59,7 +63,13 @@
>
<template #prefix>https://modrinth.com/project/</template>
</Input>
</label>
<SlugSuggestions
:selected="slug"
:suggestions="slugSuggestions"
:visible="showSlugSuggestions"
@select="selectSlugSuggestion"
/>
</div>
<div class="flex flex-col gap-2.5">
<label for="owner">
<span class="text-md font-semibold text-contrast">
@@ -150,7 +160,12 @@ import {
import { computed, defineAsyncComponent, h } from 'vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
import { validateProjectText } from '~/composables/project-text-validation'
import {
useProjectSlugSuggestions,
useSlugSuggestionVisibility,
} from '~/composables/project-slug-suggestions'
import { generateUrlSlug } from '~/utils/slugs'
import CreateLimitAlert from './CreateLimitAlert.vue'
@@ -276,6 +291,16 @@ const name = ref('')
const slug = ref('')
const description = ref('')
const manualSlug = ref(false)
const {
onFocusIn: onSlugSuggestionFocusIn,
onFocusOut: onSlugSuggestionFocusOut,
visible: showSlugSuggestions,
} = useSlugSuggestionVisibility()
const { checking: checkingSlugSuggestions, suggestions: slugSuggestions } =
useProjectSlugSuggestions({
title: name,
username: () => auth.value.user?.username,
})
const projectType = ref<ProjectTypes>('project')
const projectTypeOptions = computed<ComboboxOption<ProjectTypes>[]>(() => [
{
@@ -313,6 +338,8 @@ const disableCreate = computed(() => {
if (hasHitLimit.value) return true
if (nameValidation.value || summaryValidation.value) return true
if (!name.value.trim() || !slug.value.trim()) return true
if (!manualSlug.value && checkingSlugSuggestions.value) return true
if (!manualSlug.value && !slugSuggestions.value.includes(slug.value)) return true
if (description.value.trim().length < 3) return true
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
return true
@@ -483,6 +510,7 @@ async function show(event?: MouseEvent, options?: ShowOptions) {
slug.value = ''
description.value = ''
manualSlug.value = false
showSlugSuggestions.value = false
owner.value = 'self'
projectType.value = options?.type ?? 'project'
await fetchOrganizations()
@@ -494,4 +522,13 @@ function updatedName() {
slug.value = generateUrlSlug(name.value)
}
}
function selectSlugSuggestion(suggestion: string) {
slug.value = suggestion
manualSlug.value = true
}
watch([slugSuggestions, checkingSlugSuggestions], ([suggestions, checking]) => {
if (!manualSlug.value && !checking) slug.value = suggestions[0] ?? ''
})
</script>
@@ -0,0 +1,97 @@
import { ModrinthApiError } from '@modrinth/api-client'
import { injectModrinthClient } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { type MaybeRefOrGetter, onScopeDispose, ref, toValue, watch } from 'vue'
import { generateProjectSlugSuggestions } from '~/utils/slugs'
const STALE_TIME = 1000 * 60 * 5
const CHECK_DEBOUNCE = 300
interface ProjectSlugSuggestionOptions {
title: MaybeRefOrGetter<string>
username?: MaybeRefOrGetter<string | null | undefined>
currentProjectId?: MaybeRefOrGetter<string | null | undefined>
}
export function useSlugSuggestionVisibility() {
const visible = ref(false)
function onFocusIn() {
visible.value = true
}
function onFocusOut(event: FocusEvent) {
const container = event.currentTarget as HTMLElement
if (!container.contains(event.relatedTarget as Node | null)) visible.value = false
}
return {
onFocusIn,
onFocusOut,
visible,
}
}
export function useProjectSlugSuggestions({
title,
username,
currentProjectId,
}: ProjectSlugSuggestionOptions) {
const client = injectModrinthClient()
const queryClient = useQueryClient()
const suggestions = ref<string[]>([])
const checking = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | undefined
let requestId = 0
async function isAvailable(slug: string, projectId?: string | null) {
try {
const result = await queryClient.fetchQuery({
queryKey: ['project', 'check', slug],
queryFn: () => client.labrinth.projects_v2.check(slug),
staleTime: STALE_TIME,
retry: false,
})
return result.id === projectId
} catch (error) {
return error instanceof ModrinthApiError && error.statusCode === 404
}
}
watch(
() => [toValue(title), toValue(username), toValue(currentProjectId)] as const,
([newTitle, newUsername, projectId]) => {
if (import.meta.server) return
clearTimeout(debounceTimer)
const currentRequestId = ++requestId
const candidates = generateProjectSlugSuggestions(newTitle, newUsername)
suggestions.value = []
if (candidates.length === 0) {
checking.value = false
return
}
checking.value = true
debounceTimer = setTimeout(async () => {
const availability = await Promise.all(
candidates.map((candidate) => isAvailable(candidate, projectId)),
)
if (currentRequestId !== requestId) return
suggestions.value = candidates.filter((_, index) => availability[index])
checking.value = false
}, CHECK_DEBOUNCE)
},
{ immediate: true },
)
onScopeDispose(() => clearTimeout(debounceTimer))
return {
checking,
suggestions,
}
}
@@ -99,7 +99,7 @@ const descriptionWarning = computed(() => {
const charCount = countText(text)
if (charCount < MIN_DESCRIPTION_CHARS) {
return `It's recommended to have a description with at least ${MIN_DESCRIPTION_CHARS} readable characters. (${charCount}/${MIN_DESCRIPTION_CHARS})`
return `It's recommended to have a description with at least ${MIN_DESCRIPTION_CHARS} characters. (${charCount}/${MIN_DESCRIPTION_CHARS})`
}
return null
@@ -15,11 +15,16 @@ import {
} from '@modrinth/ui'
import ValidationMessage from '~/components/ValidationMessage.vue'
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
import { validateProjectText } from '~/composables/project-text-validation'
import {
useProjectSlugSuggestions,
useSlugSuggestionVisibility,
} from '~/composables/project-slug-suggestions'
const { formatMessage } = useVIntl()
const { projectV2: project, patchProject } = injectProjectPageContext()
const { allMembers, projectV2: project, patchProject } = injectProjectPageContext()
useProjectSettingsHeadTitle(commonProjectSettingsMessages.general)
@@ -51,6 +56,19 @@ const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
const titleValidation = computed(() => validateProjectText(current.value.title))
const taglineValidation = computed(() => validateProjectText(current.value.tagline))
const canSave = computed(() => !titleValidation.value && !taglineValidation.value)
const {
onFocusIn: onSlugSuggestionFocusIn,
onFocusOut: onSlugSuggestionFocusOut,
visible: showSlugSuggestions,
} = useSlugSuggestionVisibility()
const ownerUsername = computed(
() => (allMembers.value.find((member) => member.is_owner) ?? allMembers.value[0])?.user.username,
)
const { suggestions: slugSuggestions } = useProjectSlugSuggestions({
title: () => current.value.title,
username: ownerUsername,
currentProjectId: () => project.value.id,
})
async function save() {
if (!canSave.value) return
@@ -190,7 +208,7 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
/>
<ValidationMessage :check="taglineValidation" class="mt-2" />
</div>
<div class="mt-4">
<div class="mt-4" @focusin="onSlugSuggestionFocusIn" @focusout="onSlugSuggestionFocusOut">
<SettingsLabel id="project-url" :title="messages.urlTitle" />
<Input
id="project-url"
@@ -203,6 +221,12 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
<span class="whitespace-nowrap">https://modrinth.com/project/</span>
</template>
</Input>
<SlugSuggestions
:selected="current.url"
:suggestions="slugSuggestions"
:visible="showSlugSuggestions"
@select="current.url = $event"
/>
</div>
</div>
</div>
@@ -26,7 +26,7 @@
<ValidationMessage :check="nameValidation" class="mt-2" />
</div>
<div>
<div @focusin="onSlugSuggestionFocusIn" @focusout="onSlugSuggestionFocusOut">
<label for="project-slug">
<span class="label__title">URL</span>
</label>
@@ -44,6 +44,12 @@
</span>
</template>
</Input>
<SlugSuggestions
:selected="slug"
:suggestions="slugSuggestions"
:visible="hasPermission && showSlugSuggestions"
@select="slug = $event"
/>
</div>
<div>
@@ -325,9 +331,14 @@ import {
import { fileIsValid, formatProjectStatus } from '@modrinth/utils'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useAuth } from '~/composables/auth.js'
import { validateProjectText } from '~/composables/project-text-validation'
import {
useProjectSlugSuggestions,
useSlugSuggestionVisibility,
} from '~/composables/project-slug-suggestions'
import { fileDeclaresAi } from '~/helpers/c2pa'
import { getProjectTypeForUrl } from '~/helpers/projects.js'
@@ -338,6 +349,7 @@ const { addNotification } = injectNotificationManager()
const {
projectV3: project,
currentMember,
allMembers,
patchProjectV3,
patchIcon,
invalidate,
@@ -355,6 +367,19 @@ const formatBytes = useFormatBytes()
const name = ref(project.value.name)
const slug = ref(project.value.slug ?? '')
const summary = ref(project.value.summary)
const {
onFocusIn: onSlugSuggestionFocusIn,
onFocusOut: onSlugSuggestionFocusOut,
visible: showSlugSuggestions,
} = useSlugSuggestionVisibility()
const ownerUsername = computed(
() => (allMembers.value.find((member) => member.is_owner) ?? allMembers.value[0])?.user.username,
)
const { suggestions: slugSuggestions } = useProjectSlugSuggestions({
title: name,
username: ownerUsername,
currentProjectId: () => project.value.id,
})
const icon = ref(null)
const previewImage = ref(null)
const deletedIcon = ref(false)
+26
View File
@@ -1,4 +1,5 @@
const PROJECT_SLUG_UNSAFE_CHARS = /[^a-zA-Z0-9._-]/g
const PROJECT_SLUG_REGEX = /^[a-zA-Z0-9._-]{3,64}$/
export function generateUrlSlug(value: string) {
return value
@@ -8,3 +9,28 @@ export function generateUrlSlug(value: string) {
.replaceAll(PROJECT_SLUG_UNSAFE_CHARS, '')
.replaceAll(/--+/gm, '-')
}
export function isValidProjectSlug(value: string) {
return PROJECT_SLUG_REGEX.test(value)
}
export function generateProjectSlugSuggestions(title: string, username?: string | null) {
const titleSlug = generateUrlSlug(title)
const titleWords = title
.trim()
.split(/\s+/)
.map((word) => generateUrlSlug(word))
.filter(Boolean)
const acronym = titleWords.length > 1 ? titleWords.map((word) => word[0]).join('') : ''
const withoutDashes = titleSlug.replaceAll('-', '')
const usernameSlug = username ? generateUrlSlug(username) : ''
let withUsername = ''
if (titleSlug && usernameSlug) {
const availableTitleLength = 64 - usernameSlug.length - 1
const truncatedTitle = titleSlug.slice(0, availableTitleLength).replace(/-+$/, '')
if (truncatedTitle) withUsername = `${truncatedTitle}-${usernameSlug}`
}
return [...new Set([titleSlug, acronym, withoutDashes, withUsername])].filter(isValidProjectSlug)
}