mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
feat: add slug suggestions
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user