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
52 changes: 51 additions & 1 deletion components/BioEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@ import clsx from 'clsx'
import type { BioLanguage, BioPostContent } from '@/lib/bio-api'
import BioEditorToolbar from '@/components/BioEditorToolbar'

// Bio length targets. The team agreed bios should land around 80 words; the
// zones below give the author live feedback as they type. Boundaries are
// inclusive on the green side so 70 and 90 read as "good", not "borderline".
const BIO_TARGET_MIN = 70
const BIO_TARGET_MAX = 90
const BIO_WARNING_MIN = 60
const BIO_WARNING_MAX = 100

function countWords(text: string): number {
// Plain whitespace split is fine for the 6 European languages we support.
// CJK / Arabic would need Intl.Segmenter with granularity: 'word'.
const trimmed = text.trim()
return trimmed === '' ? 0 : trimmed.split(/\s+/).length
}

function wordCountZone(count: number): 'green' | 'orange' | 'red' {
if (count >= BIO_TARGET_MIN && count <= BIO_TARGET_MAX) return 'green'
if (count >= BIO_WARNING_MIN && count <= BIO_WARNING_MAX) return 'orange'
return 'red'
}

type EditableFields = {
member_title: string
member_linkedin_url: string
Expand Down Expand Up @@ -41,6 +62,7 @@ export default function BioEditor({
const [isDirty, setIsDirty] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isLoadingLang, setIsLoadingLang] = useState(false)
const [wordCount, setWordCount] = useState(0)
const [status, setStatus] = useState<
| { kind: 'idle' }
| { kind: 'success'; queued: string[] }
Expand Down Expand Up @@ -81,7 +103,13 @@ export default function BioEditor({
],
content: initialPost.content,
immediatelyRender: false,
onUpdate: markDirty,
onCreate({ editor }) {
setWordCount(countWords(editor.getText()))
},
onUpdate({ editor }) {
markDirty()
setWordCount(countWords(editor.getText()))
},
editorProps: {
attributes: {
// rounded-b-md (not rounded-md) + border-t-0 because the
Expand Down Expand Up @@ -135,6 +163,12 @@ export default function BioEditor({
setCurrentTitle(post.title)
setFields(postToEditable(post))
editor?.commands.setContent(post.content, { emitUpdate: false })
// setContent with emitUpdate: false suppresses onUpdate by design
// (we don't want the language switch to flip isDirty), so sync the
// word count manually here.
if (editor) {
setWordCount(countWords(editor.getText()))
}
setIsDirty(false)
} catch (err) {
setStatus({ kind: 'error', message: (err as Error).message })
Expand Down Expand Up @@ -226,6 +260,22 @@ export default function BioEditor({
<div id="bio-content" className="mt-1">
<BioEditorToolbar editor={editor} />
<EditorContent editor={editor} />
<div className="mt-1 flex items-center justify-end gap-3 text-xs">
<span
className={clsx(
'font-medium tabular-nums',
wordCountZone(wordCount) === 'green' && 'text-green-700',
wordCountZone(wordCount) === 'orange' && 'text-orange-600',
wordCountZone(wordCount) === 'red' && 'text-red-700'
)}
aria-live="polite"
>
{t('wordCount', { count: wordCount })}
</span>
<span className="text-gray-500">
{t('wordCountTarget', { min: BIO_TARGET_MIN, max: BIO_TARGET_MAX })}
</span>
</div>
</div>
</div>

Expand Down
2 changes: 2 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@
"savedNoChange": "Gespeichert.",
"saveError": "Speichern fehlgeschlagen: {error}",
"unsavedSwitch": "Du hast nicht gespeicherte \u00c4nderungen. Verwerfen und Sprache wechseln?",
"wordCount": "{count, plural, =0 {Keine W\u00f6rter} one {# Wort} other {# W\u00f6rter}}",
"wordCountTarget": "Ziel {min}-{max} W\u00f6rter",
"notLinked": "Dein Konto ist mit keinem team_member-Profil verkn\u00fcpft. Wende dich an einen Administrator.",
"noLanguages": "Es sind noch keine Sprachversionen zur Bearbeitung verf\u00fcgbar.",
"loadError": "Diese Version deiner Bio konnte nicht geladen werden.",
Expand Down
2 changes: 2 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@
"savedNoChange": "Saved.",
"saveError": "Could not save: {error}",
"unsavedSwitch": "You have unsaved changes. Discard them and switch language?",
"wordCount": "{count, plural, =0 {No words yet} one {# word} other {# words}}",
"wordCountTarget": "target {min}–{max} words",
"notLinked": "Your account isn't linked to a team_member profile. Contact an administrator.",
"noLanguages": "No language versions are available to edit yet.",
"loadError": "Could not load this version of your bio.",
Expand Down
2 changes: 2 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@
"savedNoChange": "Guardado.",
"saveError": "No se pudo guardar: {error}",
"unsavedSwitch": "Tienes cambios sin guardar. \u00bfDescartarlos y cambiar de idioma?",
"wordCount": "{count, plural, =0 {Sin palabras} one {# palabra} other {# palabras}}",
"wordCountTarget": "objetivo {min}-{max} palabras",
"notLinked": "Tu cuenta no est\u00e1 vinculada a un perfil de team_member. Contacta a un administrador.",
"noLanguages": "A\u00fan no hay versiones en otros idiomas para editar.",
"loadError": "No se pudo cargar esta versi\u00f3n de tu biograf\u00eda.",
Expand Down
2 changes: 2 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@
"savedNoChange": "Enregistr\u00e9.",
"saveError": "\u00c9chec de l'enregistrement : {error}",
"unsavedSwitch": "Vous avez des modifications non enregistr\u00e9es. Les abandonner et changer de langue ?",
"wordCount": "{count, plural, =0 {Aucun mot} one {# mot} other {# mots}}",
"wordCountTarget": "cible {min}-{max} mots",
"notLinked": "Votre compte n'est pas li\u00e9 \u00e0 un profil team_member. Contactez un administrateur.",
"noLanguages": "Aucune version linguistique n'est disponible \u00e0 la modification.",
"loadError": "Impossible de charger cette version de votre bio.",
Expand Down
2 changes: 2 additions & 0 deletions messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@
"savedNoChange": "Salvato.",
"saveError": "Impossibile salvare: {error}",
"unsavedSwitch": "Hai modifiche non salvate. Scartarle e cambiare lingua?",
"wordCount": "{count, plural, =0 {Nessuna parola} one {# parola} other {# parole}}",
"wordCountTarget": "obiettivo {min}-{max} parole",
"notLinked": "Il tuo account non \u00e8 collegato a un profilo team_member. Contatta un amministratore.",
"noLanguages": "Nessuna versione linguistica disponibile per la modifica.",
"loadError": "Impossibile caricare questa versione della tua bio.",
Expand Down
2 changes: 2 additions & 0 deletions messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@
"savedNoChange": "Guardado.",
"saveError": "N\u00e3o foi poss\u00edvel guardar: {error}",
"unsavedSwitch": "Tem altera\u00e7\u00f5es por guardar. Descart\u00e1-las e mudar de idioma?",
"wordCount": "{count, plural, =0 {Sem palavras} one {# palavra} other {# palavras}}",
"wordCountTarget": "alvo {min}-{max} palavras",
"notLinked": "A sua conta n\u00e3o est\u00e1 ligada a um perfil team_member. Contacte um administrador.",
"noLanguages": "Ainda n\u00e3o h\u00e1 vers\u00f5es lingu\u00edsticas dispon\u00edveis para edi\u00e7\u00e3o.",
"loadError": "N\u00e3o foi poss\u00edvel carregar esta vers\u00e3o da sua bio.",
Expand Down