diff --git a/client/apps/voicememos.lua b/client/apps/voicememos.lua index 1faa62dbc..6d553293b 100644 --- a/client/apps/voicememos.lua +++ b/client/apps/voicememos.lua @@ -27,8 +27,8 @@ end) ---Server push: our upload was rejected (bad payload, too long, or the upstream upload ---failed); relays the reason to the app. ---@param message string human-readable failure reason from server/voicememos/init.lua -RegisterNetEvent('sd-phone:client:voice:uploadFailed', function(message) - SendNUIMessage({ action = 'sd-phone:voice:uploadFailed', data = { message = message } }) +RegisterNetEvent('sd-phone:client:voice:uploadFailed', function(message, messageKey) + SendNUIMessage({ action = 'sd-phone:voice:uploadFailed', data = { message = message, messageKey = messageKey } }) end) -- Direct upload. The event path above hands the whole recording to the server in one ordinary diff --git a/locales/en.json b/locales/en.json index 3f6e12b61..764433739 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1188,6 +1188,7 @@ "passwordsNoMatch": "Passwords do not match.", "pleaseCheckField": "Please check this field", "pleaseWait": "Please wait…", + "pluralSuffix": "s", "poster": "poster", "previous": "Previous", "recoverAccount": "Forgot your password? Tap here to recover your account", @@ -2145,6 +2146,7 @@ "noKeyHolders": "No one else has a key.", "owned": "Owned", "properties": "properties", + "propertiesFew": "properties", "property": "property", "remove": "Remove", "removeKeyHolder": "Remove Key Holder?", @@ -4085,6 +4087,8 @@ "appTitle": "Notes", "attachPhoto": "Attach photo", "attachmentAlt": "Attachment {n}", + "attachmentDrawing": "drawing", + "attachmentPhoto": "photo", "badNoteId": "Bad note id", "cancel": "Cancel", "clearCanvas": "Clear canvas", @@ -4539,6 +4543,7 @@ "storyRoomLeft": "Your story only has room for {n} more today", "storySlide": "Slide {n}", "storyTextEmpty": "Write something first", + "submitComment": "Post", "tagPeople": "Tag people", "tagSearchHint": "Search for anyone on Photogram to tag them.", "tagline": "Sign up to see photos from your friends.", @@ -6194,6 +6199,14 @@ "pasteYoutubeNotification": "Paste any YouTube link to use it as a notification tone.", "pasteYoutubeRingtone": "Paste any YouTube link to use it as a ringtone.", "phone": "Phone", + "phoneColorBlack": "Black", + "phoneColorBlue": "Blue", + "phoneColorGreen": "Green", + "phoneColorOrange": "Orange", + "phoneColorPink": "Pink", + "phoneColorPurple": "Purple", + "phoneColorRed": "Red", + "phoneColorYellow": "Yellow", "phonePosition": "Phone Position", "phonePositionHint": "Pick where the phone snaps to inside the game window. The phone scales toward this anchor so it stays pinned when you change the size above.", "phoneScale": "Phone Scale", @@ -6484,9 +6497,21 @@ }, "share": { "acceptedYourShare": "{name} accepted your {kind}.", + "couldNotSaveSharedItem": "That {kind} could not be saved.", "couldNotSaveYourShare": "{name} could not save your {kind}.", "declinedYourShare": "{name} declined your {kind}.", "invalidRecipient": "Invalid recipient", + "kindContact": "contact", + "kindDocument": "document", + "kindIdCard": "ID card", + "kindNote": "note", + "kindPhoto": "photo", + "kindPhotogramProfile": "Photogram profile", + "kindPin": "map pin", + "kindPlaylist": "playlist", + "kindSignatureRequest": "signature request", + "kindSong": "song", + "kindVoice": "voice memo", "recipientHasTooManyPending": "Recipient has too many pending shares", "recipientNoLongerNearby": "Recipient is no longer nearby", "requestExpired": "Request expired", @@ -7134,10 +7159,12 @@ "voicemailTitle": "Voicemail" }, "voicememos": { + "badAudioPayload": "Bad audio payload", "badMemoId": "Bad memo id", "cancel": "Cancel", "copied": "Copied!", "copyLink": "Copy Link", + "couldNotSaveMemo": "Could not save memo", "couldNotSendRequest": "Could not send request", "delete": "Delete", "deleteMessage": "“{name}” will be permanently deleted.", @@ -7156,6 +7183,7 @@ "pause": "Pause", "play": "Play", "record": "Record", + "recordingTooLong": "Recording is too long", "recordingUnsupported": "Recording is not supported here.", "rename": "Rename", "renameMessage": "Enter a new name for this recording.", @@ -7169,7 +7197,10 @@ "title": "Voice Memos", "today": "Today {time}", "toneLimit": "You can save up to {n} custom tones. Remove one in Settings first.", + "uploadBusy": "Upload already in progress", "uploadFailed": "Upload failed", + "uploadLimitReached": "Upload limit reached, try again later", + "uploadSlowDown": "Slow down a moment", "useAsNotification": "Use as Notification Sound", "useAsRingtone": "Use as Ringtone", "yesterday": "Yesterday {time}" diff --git a/locales/ru.json b/locales/ru.json index fbb36ade5..e41988285 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1226,8 +1226,9 @@ "wantsYouToFollowHandle": "{name} хочет, чтобы вы подписались на @{handle} в Photogram", "welcomeBack": "С возвращением", "wiFiUnavailable": "Wi-Fi недоступен", - "wouldLikeToShare": "{name} хочет поделиться {kind}", - "yes": "Да" + "wouldLikeToShare": "{name} хочет отправить вам объект: {kind}", + "yes": "Да", + "pluralSuffix": "" }, "compass": { "dirE": "В", @@ -2140,8 +2141,8 @@ "locked": "Заблокировано", "noKeyHolders": "Больше ни у кого нет ключа.", "owned": "В собственности", - "properties": "дома", - "property": "дом", + "properties": "объектов недвижимости", + "property": "объект недвижимости", "remove": "Удалить", "removeKeyHolder": "Удалить владельца ключа?", "removeKeyMessage": "{name} больше не будет иметь ключа к {address}.", @@ -2150,9 +2151,10 @@ "sourceId": "Идентификатор источника", "title": "Дома", "type": "Тип", - "unlock": "Разблокировать", - "unlocked": "Разблокировано", - "waypoint": "Путевая точка" + "unlock": "Отпереть", + "unlocked": "Не заперто", + "waypoint": "Путевая точка", + "propertiesFew": "объекта недвижимости" }, "id": { "copied": "Скопировано", @@ -2306,7 +2308,7 @@ "moveToSpam": "Переместить в Спам", "moveToSpamConfirm": "Переместить это письмо в папку «Спам»?", "nameMustCharactersFewer": "Имя не длиннее {n} символов", - "nameMustLeastCharacters": "Имя должно быть не короче {n} символ{s}", + "nameMustLeastCharacters": "Минимальная длина имени: {n} симв.{s}", "newMessage": "Новое сообщение", "next": "Далее", "noDocuments": "Никаких документов для прикрепления. Сначала создайте его в Файлах.", @@ -4044,7 +4046,7 @@ "playPause": "Воспроизведение/Пауза", "playlist": "Плейлист", "playlistName": "Название плейлиста", - "playlistSongCount": "Плейлист · {count} песня{plural}", + "playlistSongCount": "Плейлист · Треков: {count}{plural}", "playlists": "Плейлисты", "previous": "Предыдущий", "recentlyAdded": "Недавно добавлено", @@ -4063,8 +4065,8 @@ "shareTitle": "Поделиться {title}", "sharedArtist": "Отправлено", "sharedPlaylist": "Общий плейлист", - "shuffle": "Перетасовать", - "songCount": "{count} песня{plural}", + "shuffle": "Перемешать", + "songCount": "Треков: {count}{plural}", "songs": "Песни", "sourceBlocked": "Эта ссылка не из разрешенного источника.", "stop": "Стоп", @@ -4131,7 +4133,9 @@ "strokeWidthAria": "Ширина штриха {w}", "undoStroke": "Отменить последний штрих", "viewDrawing": "Посмотреть рисунок {n}", - "viewPhoto": "Посмотреть фото {n}" + "viewPhoto": "Посмотреть фото {n}", + "attachmentDrawing": "рисунок", + "attachmentPhoto": "фото" }, "pages": { "alreadyPublished": "Этот пост уже опубликован", @@ -4489,7 +4493,7 @@ "password": "Пароль", "phone": "Телефон", "photoLabel": "📷 Фото", - "post": "Сообщение", + "post": "Публикация", "postLabel": "📷 Публикация", "postNotFound": "Пост не найден", "posts": "Сообщения", @@ -4551,7 +4555,8 @@ "you": "ты", "youAreTheHost": "Вы ведущий(-ая)", "youColon": "Вы: {body}", - "yourStory": "Ваша история" + "yourStory": "Ваша история", + "submitComment": "Отправить" }, "photos": { "add": "Добавить", @@ -5191,8 +5196,8 @@ "platePlaceholder": "например 12 АВС 34", "rateYourDriver": "Оцените своего водителя", "rating": "Рейтинг", - "ratingRider": "{rating} · Райдер", - "ratingsCount": "{n} рейтинг{plural}", + "ratingRider": "{rating} · Пассажир", + "ratingsCount": "Оценок: {n}{plural}", "requestARide": "Запросить поездку?", "requestRide": "Запросить поездку", "requestRideMessage": "Ближайшему водителю будет предложено отвезти вас до {name}, а затем предложить приемлемый для вас тариф.", @@ -5241,9 +5246,9 @@ "totalTrips": "Всего поездок", "trips": "Поездки", "tripsCount": "{n} поездок", - "tripsEarned": "{n} поездка{plural} · {amount} заработано", + "tripsEarned": "Поездок: {n}{plural} · Заработано: {amount}", "tripsTaken": "Совершено поездок", - "tripsTodayCount": "{n} поездка{plural} сегодня", + "tripsTodayCount": "Поездок сегодня: {n}{plural}", "unregister": "Отменить регистрацию", "unregisterMessage": "{vehicle}{plate} будет удален. Ваш рейтинг и количество поездок сохраняются, и вы сразу можете зарегистрировать новый автомобиль.", "unregisterTitle": "Отменить регистрацию автомобиля?", @@ -6434,7 +6439,15 @@ "youtubeLink": "Ссылка YouTube", "fromVoiceMemos": "Из Голосовых заметок", "toneLimit": "Можно сохранить до {n} собственных мелодий. Сначала удалите одну выше.", - "voiceMemoToneHint": "Выбор записи переносит её в ваши мелодии выше." + "voiceMemoToneHint": "Выбор записи переносит её в ваши мелодии выше.", + "phoneColorBlack": "Чёрный", + "phoneColorBlue": "Синий", + "phoneColorGreen": "Зелёный", + "phoneColorOrange": "Оранжевый", + "phoneColorPink": "Розовый", + "phoneColorPurple": "Фиолетовый", + "phoneColorRed": "Красный", + "phoneColorYellow": "Жёлтый" }, "setup": { "allSet": "Все готово", @@ -6485,9 +6498,21 @@ "recipientNoLongerNearby": "Получателя больше нет рядом", "requestExpired": "Срок запроса истёк", "slowDownMoment": "Не так быстро", - "tooLargeShare": "Это слишком большое, чтобы поделиться", - "tooManyPendingShares": "Слишком много ожидающих отправлений", - "unknownShareType": "Неизвестный тип отправления" + "tooLargeShare": "Объект слишком большой для передачи", + "tooManyPendingShares": "Слишком много запросов на передачу без ответа", + "unknownShareType": "Неизвестный тип передаваемого объекта", + "couldNotSaveSharedItem": "Не удалось сохранить: {kind}.", + "kindContact": "контакт", + "kindDocument": "документ", + "kindIdCard": "удостоверение", + "kindNote": "заметка", + "kindPhoto": "фото", + "kindPhotogramProfile": "профиль Photogram", + "kindPin": "метка на карте", + "kindPlaylist": "плейлист", + "kindSignatureRequest": "запрос подписи", + "kindSong": "песня", + "kindVoice": "голосовая заметка" }, "shell": { "airplaneMode": "Режим полета", @@ -7166,7 +7191,13 @@ "ringtoneSet": "Рингтон установлен", "toneLimit": "Можно сохранить до {n} собственных мелодий. Сначала удалите одну в Настройках.", "useAsNotification": "Использовать как звук уведомления", - "useAsRingtone": "Использовать как рингтон" + "useAsRingtone": "Использовать как рингтон", + "badAudioPayload": "Некорректные аудиоданные", + "couldNotSaveMemo": "Не удалось сохранить запись", + "recordingTooLong": "Запись слишком длинная", + "uploadBusy": "Запись уже загружается", + "uploadLimitReached": "Достигнут лимит загрузок. Попробуйте позже.", + "uploadSlowDown": "Подождите немного перед повторной загрузкой" }, "weather": { "closeWeather": "Закрыть Погоду", @@ -7280,9 +7311,9 @@ "standfirstPlaceholder": "Однострочное резюме, отображаемое под заголовком (необязательно).", "stories": "Истории", "storyHeadlinePlaceholder": "Заголовок материала", - "tapGearToPublish": "Нажмите на шестеренку, чтобы опубликовать первую историю.", - "tapNewPost": "Нажмите «Новая запись», чтобы опубликовать первую историю.", - "tickerSummary": "{count} заголовок{plural} · {first}", + "tapGearToPublish": "Нажмите на шестерёнку, чтобы опубликовать первый материал.", + "tapNewPost": "Нажмите «Новый пост», чтобы опубликовать первый материал.", + "tickerSummary": "Заголовков: {count}{plural} · {first}", "timeAgo": "{time} назад", "url": "URL-адрес", "viewsMillions": "{n}M", @@ -7385,4 +7416,4 @@ "noNumberToShare": "У вашего телефона нет номера, которым можно поделиться.", "theyHaveNoPhone": "У этого человека нет телефона." } -} \ No newline at end of file +} diff --git a/server/share/core.lua b/server/share/core.lua index 6d8327cf1..6ce38ec88 100644 --- a/server/share/core.lua +++ b/server/share/core.lua @@ -204,10 +204,16 @@ function core.respond(src, id, accept) -- just watch the prompt disappear: the recipient's client fires the respond callback and -- drops the card without reading the result, and the sender was only ever told on success. -- The recipient gets the handler's specific reason; the sender only needs to know it missed. - TriggerClientEvent('sd-phone:client:notify', src, { + local recipientNotice = { app = 'phone', title = 'AirShare', - body = reason or ('That %s could not be saved.'):format(what), - }) + bodyKey = 'share.couldNotSaveSharedItem', body = ('That %s could not be saved.'):format(what), + bodyVars = { kind = what }, + } + -- Handler-provided reasons are custom content, not the generic kind template. + if reason then + recipientNotice.body, recipientNotice.bodyKey, recipientNotice.bodyVars = reason, nil, nil + end + TriggerClientEvent('sd-phone:client:notify', src, recipientNotice) TriggerClientEvent('sd-phone:client:notify', req.fromSrc, { app = 'phone', title = 'AirShare', bodyKey = 'share.couldNotSaveYourShare', body = ('%s could not save your %s.'):format(who, what), diff --git a/server/voicememos/init.lua b/server/voicememos/init.lua index 242309bed..5fb1cd1ac 100644 --- a/server/voicememos/init.lua +++ b/server/voicememos/init.lua @@ -115,9 +115,9 @@ util.onCleanup(function(src) pendingDirect[src] = nil end) ---@param why string|nil 'cooldown'|'busy'|'identity'|'budget'|'server' ---@return string message local function refusal(why) - if why == 'cooldown' then return 'Slow down a moment' end - if why == 'busy' then return 'Upload already in progress' end - return 'Upload limit reached, try again later' + if why == 'cooldown' then return 'Slow down a moment', 'voicememos.uploadSlowDown' end + if why == 'busy' then return 'Upload already in progress', 'voicememos.uploadBusy' end + return 'Upload limit reached, try again later', 'voicememos.uploadLimitReached' end ---Takes a recorded memo as a base64 audio data-URL, pushes it to the media provider and saves it. @@ -129,15 +129,15 @@ end ---@param prepaid boolean|nil true when an HTTP slot already holds the budget for it local function ingest(src, audio, name, duration, prepaid) if type(audio) ~= 'string' or not lib.string.startsWith(audio, 'data:audio/') then - TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Bad audio payload') + TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Bad audio payload', 'voicememos.badAudioPayload') return end if #audio > VM.MaxAudioBytes then - TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Recording is too long') + TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Recording is too long', 'voicememos.recordingTooLong') return end if uploading[src] then - TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Upload already in progress') + TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Upload already in progress', 'voicememos.uploadBusy') return end if not prepaid then @@ -159,14 +159,14 @@ local function ingest(src, audio, name, duration, prepaid) uploading[src] = nil if not url then print(('^1[sd-phone:voice]^0 upload failed: %s'):format(tostring(err))) - TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, err or 'Upload failed') + TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Upload failed', 'voicememos.uploadFailed') return end local memo = actions.saveUploaded(src, url, name, duration) if memo then TriggerClientEvent('sd-phone:client:voice:added', src, memo) else - TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Could not save memo') + TriggerClientEvent('sd-phone:client:voice:uploadFailed', src, 'Could not save memo', 'voicememos.couldNotSaveMemo') end end) end diff --git a/web/src/apps/banking/bankingApi.ts b/web/src/apps/banking/bankingApi.ts index 32f66b7ac..af2621bb9 100644 --- a/web/src/apps/banking/bankingApi.ts +++ b/web/src/apps/banking/bankingApi.ts @@ -1,5 +1,5 @@ import { fetchNui, isFiveM } from '@/core/nui'; -import { t } from '@/i18n'; +import { getLocaleTag, t } from '@/i18n'; import { ACCOUNTS, TRANSACTIONS } from './data'; import { apiData, type Envelope } from '@/core/api'; import { formatClockTime } from '@/lib/time'; @@ -243,7 +243,7 @@ export function groupTx(txs: BankTx[]): BankDay[] { let label: string; if (key === todayK) label = t('banking.today', 'Today'); else if (key === yestK) label = t('banking.yesterday', 'Yesterday'); - else label = new Date(key + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + else label = new Date(key + 'T00:00:00').toLocaleDateString(getLocaleTag(), { weekday: 'short', month: 'short', day: 'numeric' }); return { key, label, items: items.sort((a, b) => b.date.localeCompare(a.date)) }; }); } @@ -254,5 +254,5 @@ export function txTimeLabel(dateStr: string): string { if (dateStr.slice(0, 10) === todayK) { return formatClockTime(d, true); } - return d.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }); + return d.toLocaleDateString(getLocaleTag(), { day: 'numeric', month: 'short' }); } diff --git a/web/src/apps/homes/Homes.tsx b/web/src/apps/homes/Homes.tsx index bcc661044..4c0c34192 100644 --- a/web/src/apps/homes/Homes.tsx +++ b/web/src/apps/homes/Homes.tsx @@ -13,7 +13,7 @@ import { PromptDialog } from '@/ui/PromptDialog'; import { EmptyState } from '@/ui/EmptyState'; import { NavBar } from '@/ui/NavBar'; import { HOMES, DEV_CAPS, type Home, type HomesCaps, type KeyHolder } from './data'; -import { t } from '@/i18n'; +import { getLocaleTag, t } from '@/i18n'; import { Pill } from '@/ui/Pill'; import { StatusBarSpacer } from '@/ui/StatusBarSpacer'; @@ -43,6 +43,19 @@ export function Homes({ onClose: _onClose }: { onClose: () => void }) { refetch(); }, [refetch])); const homes = list?.homes ?? (isFiveM ? [] : HOMES); + const pluralProperties = t('homes.properties', 'properties'); + const fewProperties = t('homes.propertiesFew', 'properties'); + // Only catalogs with a dedicated few form opt into locale plural rules. + // Existing catalogs retain their original count === 1 singular behavior. + const propertyForm = fewProperties !== 'properties' + ? new Intl.PluralRules(getLocaleTag()).select(homes.length) + : homes.length === 1 ? 'one' : 'other'; + const propertyLabel = propertyForm === 'one' + ? t('homes.property', 'property') + // Catalogs without a separate few form retain their existing localized plural. + : propertyForm === 'few' && fewProperties !== 'properties' + ? fewProperties + : pluralProperties; const open = homes.find(h => h.id === openId) ?? null; @@ -65,7 +78,7 @@ export function Homes({ onClose: _onClose }: { onClose: () => void }) {

{t('homes.title','Homes')}

-

{homes.length} {homes.length === 1 ? t('homes.property','property') : t('homes.properties','properties')}

+

{homes.length} {propertyLabel}

{loading && homes.length === 0 ? null : homes.length === 0 ? ( diff --git a/web/src/apps/music/Music.tsx b/web/src/apps/music/Music.tsx index 34b431272..2fc5d422b 100644 --- a/web/src/apps/music/Music.tsx +++ b/web/src/apps/music/Music.tsx @@ -6,7 +6,7 @@ import { } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; -import { t } from '@/i18n'; +import { pluralSuffix, t } from '@/i18n'; import { useSessionState, clearSessionState } from '@/hooks/useSessionState'; import { useTheme } from '@/stores/themeStore'; import { useDidEnter } from '@/hooks/useDidEnter'; @@ -233,7 +233,7 @@ export function Music({ onClose: _onClose }: { onClose: () => void }) { key={f.id} art={} title={f.name} - subtitle={t('music.playlistSongCount', 'Playlist · {count} song{plural}', { count: f.trackIds.length, plural: f.trackIds.length === 1 ? '' : 's' })} + subtitle={t('music.playlistSongCount', 'Playlist · {count} song{plural}', { count: f.trackIds.length, plural: pluralSuffix(f.trackIds.length) })} onPress={() => push({ kind: 'playlist', id: f.id })} divider={i < folders.length - 1} action={f.trackIds.length > 0 ? setSharing({ kind: 'playlist', folder: f })} label={t('music.shareName', 'Share {name}', { name: f.name })} /> : undefined} @@ -285,7 +285,7 @@ export function Music({ onClose: _onClose }: { onClose: () => void }) { {artistHits.map((a, i) => ( push({ kind: 'artist', name: a.name })} divider={i < artistHits.length - 1} art={} - title={a.name} subtitle={t('music.songCount', '{count} song{plural}', { count: a.tracks.length, plural: a.tracks.length === 1 ? '' : 's' })} /> + title={a.name} subtitle={t('music.songCount', '{count} song{plural}', { count: a.tracks.length, plural: pluralSuffix(a.tracks.length) })} /> ))} {albumHits.length > 0 && {t('music.albums', 'Albums')}} @@ -320,7 +320,7 @@ export function Music({ onClose: _onClose }: { onClose: () => void }) { ) : artists.map((a, i) => ( push({ kind: 'artist', name: a.name })} divider={i < artists.length - 1} art={} - title={a.name} subtitle={t('music.songCount', '{count} song{plural}', { count: a.tracks.length, plural: a.tracks.length === 1 ? '' : 's' })} /> + title={a.name} subtitle={t('music.songCount', '{count} song{plural}', { count: a.tracks.length, plural: pluralSuffix(a.tracks.length) })} /> ))} @@ -338,7 +338,7 @@ export function Music({ onClose: _onClose }: { onClose: () => void }) { } title={f.name} - subtitle={t('music.playlistSongCount', 'Playlist · {count} song{plural}', { count: f.trackIds.length, plural: f.trackIds.length === 1 ? '' : 's' })} + subtitle={t('music.playlistSongCount', 'Playlist · {count} song{plural}', { count: f.trackIds.length, plural: pluralSuffix(f.trackIds.length) })} onPress={() => push({ kind: 'playlist', id: f.id })} divider={i < folders.length - 1} action={f.trackIds.length > 0 ? setSharing({ kind: 'playlist', folder: f })} label={t('music.shareName', 'Share {name}', { name: f.name })} /> : undefined} @@ -448,7 +448,7 @@ export function Music({ onClose: _onClose }: { onClose: () => void }) { ) : (

{folder.name}

)} -

{t('music.songCount', '{count} song{plural}', { count: folderTracks.length, plural: folderTracks.length === 1 ? '' : 's' })}

+

{t('music.songCount', '{count} song{plural}', { count: folderTracks.length, plural: pluralSuffix(folderTracks.length) })}

diff --git a/web/src/apps/notes/NoteEditor.tsx b/web/src/apps/notes/NoteEditor.tsx index 12891443a..e95eb4ad5 100644 --- a/web/src/apps/notes/NoteEditor.tsx +++ b/web/src/apps/notes/NoteEditor.tsx @@ -369,7 +369,11 @@ export function NoteEditor({ message={t( "notes.removeAttachmentBody", "This {kind} will be removed from the note.", - { kind: pendingRemove.kind }, + { + kind: pendingRemove.kind === "photo" + ? t("notes.attachmentPhoto", "photo") + : t("notes.attachmentDrawing", "drawing"), + }, )} confirmLabel={t("notes.remove", "Remove")} cancelLabel={t("notes.cancel", "Cancel")} diff --git a/web/src/apps/photogram/feed/Comments.tsx b/web/src/apps/photogram/feed/Comments.tsx index 50ae94526..d0a5b7640 100644 --- a/web/src/apps/photogram/feed/Comments.tsx +++ b/web/src/apps/photogram/feed/Comments.tsx @@ -86,7 +86,9 @@ export function Comments({ post, me, comments, onBack, onSubmit, onToggleLike, o className="min-w-0 flex-1 bg-transparent text-[18px] text-black outline-none placeholder:text-[#9b9b9b]" />
diff --git a/web/src/apps/ryde/driver/Driver.tsx b/web/src/apps/ryde/driver/Driver.tsx index f35fa04c8..b3eadf99b 100644 --- a/web/src/apps/ryde/driver/Driver.tsx +++ b/web/src/apps/ryde/driver/Driver.tsx @@ -4,7 +4,7 @@ import { Car, ChevronRight, Download, Inbox, Navigation, Power, Star, TrendingUp import { useSessionState } from '@/hooks/useSessionState'; import { SegmentedControl } from '@/ui/SegmentedControl'; import { AlertDialog } from '@/ui/AlertDialog'; -import { t } from '@/i18n'; +import { pluralSuffix, t } from '@/i18n'; import { driverStats, useRyde } from '../store'; import { CAR_COLORS, money } from '../data'; import { BigButton } from '../ui'; @@ -140,7 +140,7 @@ function Dashboard() { {t('ryde.todaysEarnings', 'Today’s earnings')}

{money(s.earnToday)}

-

{t('ryde.tripsTodayCount', '{n} trip{plural} today', { n: s.tripsToday, plural: s.tripsToday === 1 ? '' : 's' })}

+

{t('ryde.tripsTodayCount', '{n} trip{plural} today', { n: s.tripsToday, plural: pluralSuffix(s.tripsToday) })}

- )} +
+ {/* Equal-basis side slots keep the title centred; the back slot yields first. */} +
+ {onBack && ( + + )} + {onBack && backLabel && ( + + {backLabel} + {genericLabel} + + )} +
{title && ( -
- {title} +
+ {title}
)} - {right &&
{right}
} + {/* Bar buttons are never clipped; the title gives way instead. */} +
+ {right} +
{hairline &&
}
);