Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/instructions/i18n-convert.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
applyTo: '**/*.vue'
---

You are given a Nuxt/Vue single-file component (.vue). Your task is to convert every hard-coded natural-language string in the <template> into our localization system using @vintl/vintl-nuxt (which wraps FormatJS).

Please follow these rules precisely:

1. Identify translatable strings

- Scan the <template> for all user-visible strings (inner text, alt attributes, placeholders, button labels, etc.). Do not extract dynamic expressions (like {{ user.name }}) or HTML tags. Only extract static human-readable text.

2. Create message definitions

- In the <script setup> block, import `defineMessage` or `defineMessages` from `@vintl/vintl`.
- For each extracted string, define a message with a unique `id` (use a descriptive prefix based on the component path, e.g. `auth.welcome.long-title`) and a `defaultMessage` equal to the original English string.
Example:
const messages = defineMessages({
welcomeTitle: { id: 'auth.welcome.title', defaultMessage: 'Welcome' },
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: 'You’re now part of the community…' },
})

3. Handle variables and ICU formats

- Replace dynamic parts with ICU placeholders: "Hello, ${user.name}!" → `{name}` and defaultMessage: 'Hello, {name}!'
- For numbers/dates/times, use ICU/FormatJS options (e.g., currency): `{price, number, ::currency/USD}`
- For plurals/selects, use ICU: `'{count, plural, one {# message} other {# messages}}'`

4. Rich-text messages (links/markup)

- In `defaultMessage`, wrap link/markup ranges with tags, e.g.:
"By creating an account, you agree to our <terms-link>Terms</terms-link> and <privacy-link>Privacy Policy</privacy-link>."
- Render rich-text messages with `<IntlFormatted>` from `@vintl/vintl/components` and map tags via `values`:
<IntlFormatted
:message="messages.tosLabel"
:values="{
'terms-link': (chunks) => <NuxtLink to='/terms'>{chunks}</NuxtLink>,
'privacy-link': (chunks) => <NuxtLink to='/privacy'>{chunks}</NuxtLink>,
}"
/>
- For simple emphasis: `'Welcome to <strong>Modrinth</strong>!'` and map `'strong': (c) => <strong>{c}</strong>`

5. Formatting in templates

- Import and use `useVIntl()`; prefer `formatMessage` for simple strings:
`const { formatMessage } = useVIntl()`
`<button>{{ formatMessage(messages.welcomeTitle) }}</button>`
- Vue methods like `$formatMessage`, `$formatNumber`, `$formatDate` are also available if needed.

6. Naming conventions and id stability

- Make `id`s descriptive and stable (e.g., `error.generic.default.title`). Group related messages with `defineMessages`.

7. Avoid Vue/ICU delimiter collisions

- If an ICU placeholder would end right before `}}` in a Vue template, insert a space so it becomes `} }` to avoid parsing issues.

8. Update imports and remove literals

- Ensure imports for `defineMessage`/`defineMessages`, `useVIntl`, and `<IntlFormatted>` are present. Replace all hard-coded strings with `formatMessage(...)` or `<IntlFormatted>` and remove the literals.

9. Preserve functionality

- Do not change logic, layout, reactivity, or bindings—only refactor strings into i18n.

Use existing patterns from our codebase:

- Variables/plurals: see `apps/frontend/src/pages/frog.vue`
- Rich-text link tags: see `apps/frontend/src/pages/auth/welcome.vue` and `apps/frontend/src/error.vue`

When you finish, there should be no hard-coded English strings left in the template—everything comes from `formatMessage` or `<IntlFormatted>`.
128 changes: 0 additions & 128 deletions apps/frontend/src/components/ui/CollectionCreateModal.vue

This file was deleted.

185 changes: 185 additions & 0 deletions apps/frontend/src/components/ui/create/CollectionCreateModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)">
<div class="min-w-md flex max-w-md flex-col gap-3">
<CreateLimitAlert v-model="hasHitLimit" type="collection" />
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.nameLabel) }}
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
:placeholder="formatMessage(messages.namePlaceholder)"
autocomplete="off"
:disabled="hasHitLimit"
/>
</div>
<div class="flex flex-col gap-2">
<label for="additional-information" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.summaryLabel)
}}</span>
<span>{{ formatMessage(messages.summaryDescription) }}</span>
</label>
<div class="textarea-wrapper">
<textarea
id="additional-information"
v-model="description"
maxlength="256"
:placeholder="formatMessage(messages.summaryPlaceholder)"
:disabled="hasHitLimit"
/>
</div>
</div>
<p class="m-0">
{{ formatMessage(messages.collectionInfo, { count: projectIds.length }) }}
</p>
<div class="flex justify-end gap-2">
<ButtonStyled class="w-24">
<button @click="modal.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancel) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand" class="w-36">
<button :disabled="hasHitLimit" @click="create">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.createCollection) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup>
import { PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
import { defineMessages } from '@vintl/vintl'

import CreateLimitAlert from './CreateLimitAlert.vue'

const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const router = useNativeRouter()

const messages = defineMessages({
title: {
id: 'create.collection.title',
defaultMessage: 'Creating a collection',
},
nameLabel: {
id: 'create.collection.name-label',
defaultMessage: 'Name',
},
namePlaceholder: {
id: 'create.collection.name-placeholder',
defaultMessage: 'Enter collection name...',
},
summaryLabel: {
id: 'create.collection.summary-label',
defaultMessage: 'Summary',
},
summaryDescription: {
id: 'create.collection.summary-description',
defaultMessage: 'A sentence or two that describes your collection.',
},
summaryPlaceholder: {
id: 'create.collection.summary-placeholder',
defaultMessage: 'This is a collection of...',
},
collectionInfo: {
id: 'create.collection.collection-info',
defaultMessage:
'Your new collection will be created as a public collection with {count, plural, =0 {no projects} one {# project} other {# projects}}.',
},
cancel: {
id: 'create.collection.cancel',
defaultMessage: 'Cancel',
},
createCollection: {
id: 'create.collection.create-collection',
defaultMessage: 'Create collection',
},
errorTitle: {
id: 'create.collection.error-title',
defaultMessage: 'An error occurred',
},
})

const name = ref('')
const description = ref('')
const hasHitLimit = ref(false)

const modal = ref()

const props = defineProps({
projectIds: {
type: Array,
default() {
return []
},
},
})

async function create() {
startLoading()
try {
const result = await useBaseFetch('collection', {
method: 'POST',
body: {
name: name.value.trim(),
description: description.value.trim() || undefined,
projects: props.projectIds,
},
apiVersion: 3,
})

await initUserCollections()

modal.value.hide()
await router.push(`/collection/${result.id}`)
} catch (err) {
addNotification({
title: formatMessage(messages.errorTitle),
text: err?.data?.description || err?.message || err,
type: 'error',
})
}
stopLoading()
}
function show(event) {
name.value = ''
description.value = ''
modal.value.show(event)
}

defineExpose({
show,
})
</script>

<style scoped lang="scss">
.modal-creation {
input {
width: 20rem;
max-width: 100%;
}

.text-input-wrapper {
width: 100%;
}

textarea {
min-height: 5rem;
}

.input-group {
margin-top: var(--gap-md);
}
}
</style>
Loading