+
+
https://modrinth.com/project/
-
+
+
-
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/index.vue b/apps/frontend/src/pages/[type]/[project]/settings/index.vue
index 514c6805fb..8edcc4ac9b 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/index.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/index.vue
@@ -26,7 +26,7 @@
-
+
@@ -44,6 +44,12 @@
+
@@ -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)
diff --git a/apps/frontend/src/utils/slugs.ts b/apps/frontend/src/utils/slugs.ts
index 3f123d1fe3..525331d365 100644
--- a/apps/frontend/src/utils/slugs.ts
+++ b/apps/frontend/src/utils/slugs.ts
@@ -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)
+}