Shotstash
Contributor guide

Translations

How text is stored, how the locale is chosen and how to add a language.

Shotstash ships in English. Every piece of text a user can see lives in messages/<locale>.json; the code only holds keys. English (messages/en.json) is the source that every other locale file mirrors. The app uses next-intl without locale routing: there are no locale prefixes in URLs.

How the locale is chosen

  1. The signed-in user's language (users.locale, set in Profile settings). The browser copies it into the shotstash_locale cookie so server-rendered pages match.
  2. The shotstash_locale cookie (server components, and anonymous visitors of share pages).
  3. The instance default, SHOTSTASH_DEFAULT_LOCALE in .env.
  4. English.

An unknown or unsupported value at any step is skipped. Emails follow the recipient's users.locale, then SHOTSTASH_DEFAULT_LOCALE, then English. Times shown in the browser use the viewer's own time zone; server-rendered text (emails, share pages) uses SHOTSTASH_DEFAULT_TIMEZONE (UTC by default). Absolute times always carry a zone label.

Add a locale

  1. Copy messages/en.json to messages/<locale>.json (for example messages/pt-BR.json) and translate the values. Keep every key and every ICU placeholder ({count}, {name}, {productName}); translate the words inside plural branches ({count, plural, one {# file} other {# files}}), keeping the branch names your language needs.
  2. Add the locale to SUPPORTED_LOCALES and its name, written in the language itself, to LOCALE_NAMES in src/i18n/config.ts.
  3. Register the file in MESSAGES in src/modules/i18n/index.ts (module code such as emails uses it).
  4. Spell the tag in canonical BCP 47 form, language lowercase and region uppercase (en, pt-BR): the database only accepts that shape for users.locale. Lookups ignore case (pt-br from SHOTSTASH_DEFAULT_LOCALE or the cookie resolves to pt-BR), and the canonical spelling is what gets stored.
  5. Run the checks below and open a pull request. Missing keys fail at build time for English and show the key in other locales, so keep your file complete.

Write code that stays translatable

  • Client components: useTranslations('<namespace>'); numbers, sizes, dates and relative times through useFormat() from src/i18n/useFormat.ts.
  • Server components under src/app: getTranslations / getLocale from next-intl/server.
  • Module code (emails, notifications, background jobs): translatorFor(locale) from @/modules/i18n, always with an explicit locale; never the request-scoped APIs.
  • src/lib cannot import the i18n layer: helpers there take the locale or the words as arguments.
  • Counts are ICU plurals, never string concatenation.
  • Server errors: the server answers a stable code (REST { code, message }, GraphQL extensions.code, admin payload errorCode) plus an English developer message that is never shown. The client renders errors.codes.<CODE> through useHumanizeError() and never matches message text. A new code needs a message in errors.codes; npm test fails otherwise.
  • Notifications are rendered from their type and data; the stored English title and body are only a fallback for old rows.

Checks

npm run lint             # i18next/no-literal-string: literal JSX text and copy attributes are errors in all of src
npm run i18n:check       # Indonesian leftover words anywhere in src and messages/*.json (CI runs it)
npm test                 # includes the error-code, notification, email and gate tests

i18n:check looks for a closed list of Indonesian function words (scripts/indonesian-leftovers.json) in string literals, template text and JSX text; comments and GraphQL documents are ignored. Technical text that is never shown to users is listed with a reason in scripts/i18n-scope.json under exceptions (today: GraphQL SDL comments and GLSL shader source). An exception that no longer matches anything fails the check, and nothing user-visible may be listed there. A single line can opt out with an i18n-ignore comment, which reviewers should question. If your locale legitimately uses one of the listed words, say so in the pull request so the check can learn per-locale word lists.

Copy rules

  • English, sentence case, warm and short. Talk to the user as "you". No jargon: "Search footage…", not "Search assets…". "Footage" is the word for the media people shoot and share (search, uploads, share pages); "file" is one item in a Section.
  • One word per level: Project, Section, File. Other terms: Trash, Shared. Never "folder" for a Section.
  • Role words in full: super admin, admin, crew, editor, viewer. Never show enum values.
  • One word for dismissing a dialog: "Cancel".
  • The product name is always the {productName} placeholder (from src/lib/brand.ts).
  • Errors say what happened and what to do next ("Couldn't load. Try again."), never raw technical text.
  • Empty states invite an action. Pointer words ("click", "hover", "drag") only in text shown to hover-capable pointers; text that touch screens also see says "Tap", or uses a neutral verb such as "select" or "choose".
  • Numbers and units readable: "2,600 files", "3.8 MB". Camera (EXIF) time is labelled "(camera time)" and never converted.
  • One ellipsis character, "…", never three dots.
  • No em dash characters.

This page is generated from docs/i18n.md in the repository; edit it there.

On this page