Skip to content

Commit d418eae

Browse files
authored
feat: create modal limit alerting (modrinth#4429)
* draft: layout for alert * feat: simplify * feat: remove dummy data * fix: lint and widths * feat: use chips rather than dropdown select * feat: remove gap from admonition header v body * Revert "feat: remove gap from admonition header v body" This reverts commit 46cce52. * fix: niche fixes * feat: update for new backend structure * fix: i18n
1 parent f466470 commit d418eae

17 files changed

Lines changed: 785 additions & 194 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
applyTo: '**/*.vue'
3+
---
4+
5+
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).
6+
7+
Please follow these rules precisely:
8+
9+
1. Identify translatable strings
10+
11+
- 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.
12+
13+
2. Create message definitions
14+
15+
- In the <script setup> block, import `defineMessage` or `defineMessages` from `@vintl/vintl`.
16+
- 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.
17+
Example:
18+
const messages = defineMessages({
19+
welcomeTitle: { id: 'auth.welcome.title', defaultMessage: 'Welcome' },
20+
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: 'You’re now part of the community…' },
21+
})
22+
23+
3. Handle variables and ICU formats
24+
25+
- Replace dynamic parts with ICU placeholders: "Hello, ${user.name}!" → `{name}` and defaultMessage: 'Hello, {name}!'
26+
- For numbers/dates/times, use ICU/FormatJS options (e.g., currency): `{price, number, ::currency/USD}`
27+
- For plurals/selects, use ICU: `'{count, plural, one {# message} other {# messages}}'`
28+
29+
4. Rich-text messages (links/markup)
30+
31+
- In `defaultMessage`, wrap link/markup ranges with tags, e.g.:
32+
"By creating an account, you agree to our <terms-link>Terms</terms-link> and <privacy-link>Privacy Policy</privacy-link>."
33+
- Render rich-text messages with `<IntlFormatted>` from `@vintl/vintl/components` and map tags via `values`:
34+
<IntlFormatted
35+
:message="messages.tosLabel"
36+
:values="{
37+
'terms-link': (chunks) => <NuxtLink to='/terms'>{chunks}</NuxtLink>,
38+
'privacy-link': (chunks) => <NuxtLink to='/privacy'>{chunks}</NuxtLink>,
39+
}"
40+
/>
41+
- For simple emphasis: `'Welcome to <strong>Modrinth</strong>!'` and map `'strong': (c) => <strong>{c}</strong>`
42+
43+
5. Formatting in templates
44+
45+
- Import and use `useVIntl()`; prefer `formatMessage` for simple strings:
46+
`const { formatMessage } = useVIntl()`
47+
`<button>{{ formatMessage(messages.welcomeTitle) }}</button>`
48+
- Vue methods like `$formatMessage`, `$formatNumber`, `$formatDate` are also available if needed.
49+
50+
6. Naming conventions and id stability
51+
52+
- Make `id`s descriptive and stable (e.g., `error.generic.default.title`). Group related messages with `defineMessages`.
53+
54+
7. Avoid Vue/ICU delimiter collisions
55+
56+
- If an ICU placeholder would end right before `}}` in a Vue template, insert a space so it becomes `} }` to avoid parsing issues.
57+
58+
8. Update imports and remove literals
59+
60+
- Ensure imports for `defineMessage`/`defineMessages`, `useVIntl`, and `<IntlFormatted>` are present. Replace all hard-coded strings with `formatMessage(...)` or `<IntlFormatted>` and remove the literals.
61+
62+
9. Preserve functionality
63+
64+
- Do not change logic, layout, reactivity, or bindings—only refactor strings into i18n.
65+
66+
Use existing patterns from our codebase:
67+
68+
- Variables/plurals: see `apps/frontend/src/pages/frog.vue`
69+
- Rich-text link tags: see `apps/frontend/src/pages/auth/welcome.vue` and `apps/frontend/src/error.vue`
70+
71+
When you finish, there should be no hard-coded English strings left in the template—everything comes from `formatMessage` or `<IntlFormatted>`.

‎apps/frontend/src/components/ui/CollectionCreateModal.vue‎

Lines changed: 0 additions & 128 deletions
This file was deleted.
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<template>
2+
<NewModal ref="modal" :header="formatMessage(messages.title)">
3+
<div class="min-w-md flex max-w-md flex-col gap-3">
4+
<CreateLimitAlert v-model="hasHitLimit" type="collection" />
5+
<div class="flex flex-col gap-2">
6+
<label for="name">
7+
<span class="text-lg font-semibold text-contrast">
8+
{{ formatMessage(messages.nameLabel) }}
9+
<span class="text-brand-red">*</span>
10+
</span>
11+
</label>
12+
<input
13+
id="name"
14+
v-model="name"
15+
type="text"
16+
maxlength="64"
17+
:placeholder="formatMessage(messages.namePlaceholder)"
18+
autocomplete="off"
19+
:disabled="hasHitLimit"
20+
/>
21+
</div>
22+
<div class="flex flex-col gap-2">
23+
<label for="additional-information" class="flex flex-col gap-1">
24+
<span class="text-lg font-semibold text-contrast">{{
25+
formatMessage(messages.summaryLabel)
26+
}}</span>
27+
<span>{{ formatMessage(messages.summaryDescription) }}</span>
28+
</label>
29+
<div class="textarea-wrapper">
30+
<textarea
31+
id="additional-information"
32+
v-model="description"
33+
maxlength="256"
34+
:placeholder="formatMessage(messages.summaryPlaceholder)"
35+
:disabled="hasHitLimit"
36+
/>
37+
</div>
38+
</div>
39+
<p class="m-0">
40+
{{ formatMessage(messages.collectionInfo, { count: projectIds.length }) }}
41+
</p>
42+
<div class="flex justify-end gap-2">
43+
<ButtonStyled class="w-24">
44+
<button @click="modal.hide()">
45+
<XIcon aria-hidden="true" />
46+
{{ formatMessage(messages.cancel) }}
47+
</button>
48+
</ButtonStyled>
49+
<ButtonStyled color="brand" class="w-36">
50+
<button :disabled="hasHitLimit" @click="create">
51+
<PlusIcon aria-hidden="true" />
52+
{{ formatMessage(messages.createCollection) }}
53+
</button>
54+
</ButtonStyled>
55+
</div>
56+
</div>
57+
</NewModal>
58+
</template>
59+
<script setup>
60+
import { PlusIcon, XIcon } from '@modrinth/assets'
61+
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
62+
import { defineMessages } from '@vintl/vintl'
63+
64+
import CreateLimitAlert from './CreateLimitAlert.vue'
65+
66+
const { addNotification } = injectNotificationManager()
67+
const { formatMessage } = useVIntl()
68+
const router = useNativeRouter()
69+
70+
const messages = defineMessages({
71+
title: {
72+
id: 'create.collection.title',
73+
defaultMessage: 'Creating a collection',
74+
},
75+
nameLabel: {
76+
id: 'create.collection.name-label',
77+
defaultMessage: 'Name',
78+
},
79+
namePlaceholder: {
80+
id: 'create.collection.name-placeholder',
81+
defaultMessage: 'Enter collection name...',
82+
},
83+
summaryLabel: {
84+
id: 'create.collection.summary-label',
85+
defaultMessage: 'Summary',
86+
},
87+
summaryDescription: {
88+
id: 'create.collection.summary-description',
89+
defaultMessage: 'A sentence or two that describes your collection.',
90+
},
91+
summaryPlaceholder: {
92+
id: 'create.collection.summary-placeholder',
93+
defaultMessage: 'This is a collection of...',
94+
},
95+
collectionInfo: {
96+
id: 'create.collection.collection-info',
97+
defaultMessage:
98+
'Your new collection will be created as a public collection with {count, plural, =0 {no projects} one {# project} other {# projects}}.',
99+
},
100+
cancel: {
101+
id: 'create.collection.cancel',
102+
defaultMessage: 'Cancel',
103+
},
104+
createCollection: {
105+
id: 'create.collection.create-collection',
106+
defaultMessage: 'Create collection',
107+
},
108+
errorTitle: {
109+
id: 'create.collection.error-title',
110+
defaultMessage: 'An error occurred',
111+
},
112+
})
113+
114+
const name = ref('')
115+
const description = ref('')
116+
const hasHitLimit = ref(false)
117+
118+
const modal = ref()
119+
120+
const props = defineProps({
121+
projectIds: {
122+
type: Array,
123+
default() {
124+
return []
125+
},
126+
},
127+
})
128+
129+
async function create() {
130+
startLoading()
131+
try {
132+
const result = await useBaseFetch('collection', {
133+
method: 'POST',
134+
body: {
135+
name: name.value.trim(),
136+
description: description.value.trim() || undefined,
137+
projects: props.projectIds,
138+
},
139+
apiVersion: 3,
140+
})
141+
142+
await initUserCollections()
143+
144+
modal.value.hide()
145+
await router.push(`/collection/${result.id}`)
146+
} catch (err) {
147+
addNotification({
148+
title: formatMessage(messages.errorTitle),
149+
text: err?.data?.description || err?.message || err,
150+
type: 'error',
151+
})
152+
}
153+
stopLoading()
154+
}
155+
function show(event) {
156+
name.value = ''
157+
description.value = ''
158+
modal.value.show(event)
159+
}
160+
161+
defineExpose({
162+
show,
163+
})
164+
</script>
165+
166+
<style scoped lang="scss">
167+
.modal-creation {
168+
input {
169+
width: 20rem;
170+
max-width: 100%;
171+
}
172+
173+
.text-input-wrapper {
174+
width: 100%;
175+
}
176+
177+
textarea {
178+
min-height: 5rem;
179+
}
180+
181+
.input-group {
182+
margin-top: var(--gap-md);
183+
}
184+
}
185+
</style>

0 commit comments

Comments
 (0)