Yōten: A social platform for tracking the essential points of your language learning yoten.app
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: support I-639-[1|2|3]

Signed-off-by: brookjeynes <me@brookjeynes.dev>

author
brookjeynes
committer
Tangled
date (May 23, 2026, 3:21 PM +0300) commit ca13e7d1 parent 16ea958a change-id txruyzoz
+377 -266
+1 -1
api/yoten/feedsession.go
··· 25 25 Description *string `json:"description,omitempty" cborgen:"description,omitempty"` 26 26 // duration: Duration of the study session in seconds. 27 27 Duration int64 `json:"duration" cborgen:"duration"` 28 - // languageCode: An ISO 639-1 two-letter language code (e.g., 'en', 'es', 'ko'). 28 + // languageCode: An ISO 639-1, 639-2, or 639-3 language code (2–3 characters, e.g., 'en', 'eng', 'asl'). 29 29 LanguageCode string `json:"languageCode" cborgen:"languageCode"` 30 30 // predefinedActivityName: A key used to lookup activity type details. 31 31 PredefinedActivityName *string `json:"predefinedActivityName,omitempty" cborgen:"predefinedActivityName,omitempty"`
+4 -6
go.mod
··· 4 4 5 5 require ( 6 6 github.com/a-h/templ v0.3.898 7 + github.com/barbashov/iso639-3 v1.0.0 7 8 github.com/bluesky-social/indigo v0.0.0-20260520161040-0eb7e0ea71bc 8 9 github.com/bluesky-social/jetstream v0.0.0-20260415170838-8a65de4eda28 9 10 github.com/carlmjohnson/versioninfo v0.22.5 10 11 github.com/charmbracelet/log v0.4.2 11 12 github.com/go-chi/chi/v5 v5.2.1 12 13 github.com/gorilla/sessions v1.4.0 14 + github.com/ipfs/go-cid v0.4.1 13 15 github.com/mattn/go-sqlite3 v1.14.22 14 16 github.com/posthog/posthog-go v1.5.12 15 17 github.com/redis/go-redis/v9 v9.14.0 18 + github.com/sahilm/fuzzy v0.1.2 16 19 github.com/sethvargo/go-envconfig v1.3.0 17 20 github.com/whyrusleeping/cbor-gen v0.3.1 18 21 golang.org/x/net v0.42.0 19 22 golang.org/x/sync v0.16.0 20 23 golang.org/x/text v0.28.0 24 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 21 25 ) 22 26 23 27 require ( ··· 33 37 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 34 38 github.com/charmbracelet/x/term v0.2.1 // indirect 35 39 github.com/cli/browser v1.3.0 // indirect 36 - github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect 37 40 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 38 41 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 39 42 github.com/fatih/color v1.16.0 // indirect ··· 45 48 github.com/gorilla/securecookie v1.1.2 // indirect 46 49 github.com/gorilla/websocket v1.5.1 // indirect 47 50 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 48 - github.com/ipfs/go-cid v0.4.1 // indirect 49 51 github.com/klauspost/compress v1.17.9 // indirect 50 52 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 51 53 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect ··· 66 68 github.com/prometheus/common v0.54.0 // indirect 67 69 github.com/prometheus/procfs v0.15.1 // indirect 68 70 github.com/rivo/uniseg v0.4.7 // indirect 69 - github.com/russross/blackfriday/v2 v2.1.0 // indirect 70 71 github.com/spaolacci/murmur3 v1.1.0 // indirect 71 - github.com/urfave/cli/v2 v2.26.0 // indirect 72 72 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 73 - github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect 74 73 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 75 74 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 76 75 go.uber.org/atomic v1.11.0 // indirect ··· 80 79 golang.org/x/sys v0.34.0 // indirect 81 80 golang.org/x/time v0.8.0 // indirect 82 81 golang.org/x/tools v0.35.0 // indirect 83 - golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 84 82 google.golang.org/protobuf v1.34.2 // indirect 85 83 lukechampine.com/blake3 v1.2.1 // indirect 86 84 )
+6 -9
go.sum
··· 6 6 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 7 7 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 8 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 + github.com/barbashov/iso639-3 v1.0.0 h1:qCp1hUzZT8C8yHcdDo4sZQg2jHEaX6LF5H/dF9ba0qs= 10 + github.com/barbashov/iso639-3 v1.0.0/go.mod h1:rGod7o6KPeJ+hyBpHfhi4v7blx9sf+QsHsA7KAsdN6U= 9 11 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 10 12 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 11 13 github.com/bluesky-social/indigo v0.0.0-20260520161040-0eb7e0ea71bc h1:85eg3bXOOCfBTKL+otMZaBjDRbMnk3eJp+xER4h3KKM= ··· 36 38 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 37 39 github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= 38 40 github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= 39 - github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= 40 - github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 41 41 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 42 42 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 43 43 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= ··· 77 77 github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 78 78 github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 79 79 github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 80 + github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 81 + github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 80 82 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 81 83 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 82 84 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= ··· 123 125 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 124 126 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 125 127 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 126 - github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 127 - github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 128 + github.com/sahilm/fuzzy v0.1.2 h1:kdSkz23lx1meNjEl+SLJULeSbjTI4Dn14K/YxdGrIww= 129 + github.com/sahilm/fuzzy v0.1.2/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= 128 130 github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U= 129 131 github.com/sethvargo/go-envconfig v1.3.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= 130 132 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 131 133 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 132 134 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 133 135 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 134 - github.com/urfave/cli v1.22.5 h1:lNq9sAHXK2qfdI8W+GRItjCEkI+2oR4d+MEHy1CKXoU= 135 - github.com/urfave/cli/v2 v2.26.0 h1:3f3AMg3HpThFNT4I++TKOejZO8yU55t3JnnSr4S4QEI= 136 - github.com/urfave/cli/v2 v2.26.0/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= 137 136 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 138 137 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 139 138 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 140 139 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 141 - github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI= 142 - github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 143 140 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 144 141 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 145 142 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
+2 -2
internal/consumer/ingester.go
··· 117 117 var languages []domain.Language 118 118 for idx, languageString := range record.Languages { 119 119 if idx < domain.MaxLanguages { 120 - language, ok := database.Languages[domain.LanguageCode(languageString)] 120 + language, ok := database.LookupLanguage(domain.LanguageCode(languageString)) 121 121 if !ok { 122 122 return fmt.Errorf("failed to find language '%s'", languageString) 123 123 } ··· 196 196 } 197 197 198 198 languageCode := record.LanguageCode 199 - language, ok := database.Languages[domain.LanguageCode(languageCode)] 199 + language, ok := database.LookupLanguage(domain.LanguageCode(languageCode)) 200 200 if !ok { 201 201 return fmt.Errorf("failed to find language '%s'", languageCode) 202 202 }
+104
internal/db/db.go
··· 398 398 return err 399 399 }) 400 400 401 + // This migration loosens the language_code length constraint from exactly 402 + // 2 characters (ISO 639-1) to 2-3 characters to support ISO 639-2 and 403 + // 639-3 codes (e.g. sign languages like 'asl'). 404 + runMigration(conn, logger, "iso639-3-language-codes", func(tx *sql.Tx) error { 405 + _, err := tx.Exec(` 406 + create table if not exists profile_languages_new ( 407 + -- id 408 + did text not null, 409 + 410 + -- data 411 + language_code text not null, 412 + 413 + -- constraints 414 + primary key (did, language_code), 415 + check (length(language_code) between 2 and 3), 416 + foreign key (did) references profiles(did) on delete cascade 417 + ); 418 + `) 419 + if err != nil { 420 + return err 421 + } 422 + 423 + _, err = tx.Exec(` 424 + insert into profile_languages_new (did, language_code) 425 + select did, language_code from profile_languages; 426 + `) 427 + if err != nil { 428 + return err 429 + } 430 + 431 + _, err = tx.Exec(`drop table profile_languages`) 432 + if err != nil { 433 + return err 434 + } 435 + 436 + _, err = tx.Exec(`alter table profile_languages_new rename to profile_languages`) 437 + if err != nil { 438 + return err 439 + } 440 + 441 + _, err = tx.Exec(` 442 + create table if not exists study_sessions_new ( 443 + -- id 444 + did text not null, 445 + rkey text not null, 446 + 447 + -- data 448 + activity_id integer not null, 449 + resource_id integer, 450 + description text, 451 + duration integer not null, 452 + language_code text not null, 453 + date text not null, 454 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 455 + 456 + -- constraints 457 + check (length(language_code) between 2 and 3), 458 + foreign key (did) references profiles(did) on delete cascade, 459 + foreign key (activity_id) references activities(id) on delete restrict, 460 + foreign key (resource_id) references resources(id) on delete set null, 461 + primary key (did, rkey) 462 + ); 463 + `) 464 + if err != nil { 465 + return err 466 + } 467 + 468 + _, err = tx.Exec(` 469 + insert into study_sessions_new ( 470 + did, 471 + rkey, 472 + activity_id, 473 + resource_id, 474 + description, 475 + duration, 476 + language_code, 477 + date, 478 + created 479 + ) 480 + select 481 + did, 482 + rkey, 483 + activity_id, 484 + resource_id, 485 + description, 486 + duration, 487 + language_code, 488 + date, 489 + created 490 + from study_sessions; 491 + `) 492 + if err != nil { 493 + return err 494 + } 495 + 496 + _, err = tx.Exec(`drop table study_sessions`) 497 + if err != nil { 498 + return err 499 + } 500 + 501 + _, err = tx.Exec(`alter table study_sessions_new rename to study_sessions`) 502 + return err 503 + }) 504 + 401 505 logger.Debug("successfully applied migrations") 402 506 403 507 return &DB{
+103 -141
internal/db/language.go
··· 1 1 package db 2 2 3 - import "yoten.app/internal/domain" 3 + import ( 4 + "sort" 4 5 5 - var Languages = map[domain.LanguageCode]domain.Language{ 6 - "aa": {Code: "aa", Name: "Afar", Flag: "🇪🇹"}, 7 - "ab": {Code: "ab", Name: "Abkhazian", Flag: "🇬🇪"}, 8 - "af": {Code: "af", Name: "Afrikaans", Flag: "🇿🇦"}, 9 - "am": {Code: "am", Name: "Amharic", Flag: "🇪🇹"}, 10 - "ar": {Code: "ar", Name: "Arabic", NativeName: ToPtr("العربية"), Flag: "🇸🇦"}, 11 - "as": {Code: "as", Name: "Assamese", Flag: "🇮🇳"}, 12 - "ay": {Code: "ay", Name: "Aymara", Flag: "🇧🇴"}, 13 - "az": {Code: "az", Name: "Azerbaijani", Flag: "🇦🇿"}, 14 - "ba": {Code: "ba", Name: "Bashkir", Flag: "🇷🇺"}, 15 - "be": {Code: "be", Name: "Byelorussian", Flag: "🇧🇾"}, 16 - "bg": {Code: "bg", Name: "Bulgarian", NativeName: ToPtr("български"), Flag: "🇧🇬"}, 17 - "bh": {Code: "bh", Name: "Bihari", Flag: "🇮🇳"}, 18 - "bi": {Code: "bi", Name: "Bislama", Flag: "🇻🇺"}, 19 - "bn": {Code: "bn", Name: "Bengali", NativeName: ToPtr("বাংলা"), Flag: "🇧🇩"}, 20 - "bo": {Code: "bo", Name: "Tibetan", Flag: "🇨🇳"}, 21 - "br": {Code: "br", Name: "Breton", Flag: "🇫🇷"}, 22 - "ca": {Code: "ca", Name: "Catalan", Flag: "🇪🇸"}, 23 - "co": {Code: "co", Name: "Corsican", Flag: "🇫🇷"}, 24 - "cs": {Code: "cs", Name: "Czech", NativeName: ToPtr("Čeština"), Flag: "🇨🇿"}, 25 - "cy": {Code: "cy", Name: "Welsh", Flag: "🏴󠁧󠁢󠁷󠁬󠁳󠁿"}, 26 - "da": {Code: "da", Name: "Danish", NativeName: ToPtr("Dansk"), Flag: "🇩🇰"}, 27 - "de": {Code: "de", Name: "German", NativeName: ToPtr("Deutsch"), Flag: "🇩🇪"}, 28 - "dz": {Code: "dz", Name: "Bhutani", Flag: "🇧🇹"}, 29 - "el": {Code: "el", Name: "Greek", NativeName: ToPtr("Ελληνικά"), Flag: "🇬🇷"}, 30 - "en": {Code: "en", Name: "English", Flag: "🇬🇧"}, 31 - "eo": {Code: "eo", Name: "Esperanto", Flag: "🌍"}, 32 - "es": {Code: "es", Name: "Spanish", NativeName: ToPtr("Español"), Flag: "🇪🇸"}, 33 - "et": {Code: "et", Name: "Estonian", Flag: "🇪🇪"}, 34 - "eu": {Code: "eu", Name: "Basque", Flag: "🇪🇸"}, 35 - "fa": {Code: "fa", Name: "Persian", Flag: "🇮🇷"}, 36 - "fi": {Code: "fi", Name: "Finnish", NativeName: ToPtr("Suomi"), Flag: "🇫🇮"}, 37 - "fj": {Code: "fj", Name: "Fiji", Flag: "🇫🇯"}, 38 - "fo": {Code: "fo", Name: "Faroese", Flag: "🇫🇴"}, 39 - "fr": {Code: "fr", Name: "French", NativeName: ToPtr("Français"), Flag: "🇫🇷"}, 40 - "fy": {Code: "fy", Name: "Frisian", Flag: "🇳🇱"}, 41 - "ga": {Code: "ga", Name: "Irish", Flag: "🇮🇪"}, 42 - "gd": {Code: "gd", Name: "Gaelic (Scottish)", Flag: "🏴󠁧󠁢󠁳󠁣󠁴󠁿"}, 43 - "gl": {Code: "gl", Name: "Galician", Flag: "🇪🇸"}, 44 - "gn": {Code: "gn", Name: "Guarani", Flag: "🇵🇾"}, 45 - "gu": {Code: "gu", Name: "Gujarati", Flag: "🇮🇳"}, 46 - "ha": {Code: "ha", Name: "Hausa", Flag: "🇳🇬"}, 47 - "he": {Code: "he", Name: "Hebrew", NativeName: ToPtr("עברית"), Flag: "🇮🇱"}, 48 - "hi": {Code: "hi", Name: "Hindi", NativeName: ToPtr("हिन्दी"), Flag: "🇮🇳"}, 49 - "hr": {Code: "hr", Name: "Croatian", Flag: "🇭🇷"}, 50 - "hu": {Code: "hu", Name: "Hungarian", NativeName: ToPtr("Magyar"), Flag: "🇭🇺"}, 51 - "hy": {Code: "hy", Name: "Armenian", Flag: "🇦🇲"}, 52 - "ia": {Code: "ia", Name: "Interlingua", Flag: "🌍"}, 53 - "id": {Code: "id", Name: "Indonesian", Flag: "🇮🇩"}, 54 - "ie": {Code: "ie", Name: "Interlingue", Flag: "🌍"}, 55 - "ik": {Code: "ik", Name: "Inupiak", Flag: "🇺🇸"}, 56 - "is": {Code: "is", Name: "Icelandic", Flag: "🇮🇸"}, 57 - "it": {Code: "it", Name: "Italian", NativeName: ToPtr("Italiano"), Flag: "🇮🇹"}, 58 - "iu": {Code: "iu", Name: "Inuktitut", Flag: "🇨🇦"}, 59 - "ja": {Code: "ja", Name: "Japanese", NativeName: ToPtr("日本語"), Flag: "🇯🇵"}, 60 - "jw": {Code: "jw", Name: "Javanese", Flag: "🇮🇩"}, 61 - "ka": {Code: "ka", Name: "Georgian", Flag: "🇬🇪"}, 62 - "kk": {Code: "kk", Name: "Kazakh", Flag: "🇰🇿"}, 63 - "kl": {Code: "kl", Name: "Greenlandic", Flag: "🇬🇱"}, 64 - "km": {Code: "km", Name: "Cambodian", Flag: "🇰🇭"}, 65 - "kn": {Code: "kn", Name: "Kannada", Flag: "🇮🇳"}, 66 - "ko": {Code: "ko", Name: "Korean", NativeName: ToPtr("한국어"), Flag: "🇰🇷"}, 67 - "ks": {Code: "ks", Name: "Kashmiri", Flag: "🇮🇳"}, 68 - "ku": {Code: "ku", Name: "Kurdish", Flag: "🇮🇶"}, 69 - "ky": {Code: "ky", Name: "Kirghiz", Flag: "🇰🇬"}, 70 - "la": {Code: "la", Name: "Latin", Flag: "🇻🇦"}, 71 - "ln": {Code: "ln", Name: "Lingala", Flag: "🇨🇩"}, 72 - "lo": {Code: "lo", Name: "Laothian", Flag: "🇱🇦"}, 73 - "lt": {Code: "lt", Name: "Lithuanian", Flag: "🇱🇹"}, 74 - "lv": {Code: "lv", Name: "Latvian", Flag: "🇱🇻"}, 75 - "mg": {Code: "mg", Name: "Malagasy", Flag: "🇲🇬"}, 76 - "mi": {Code: "mi", Name: "Maori", Flag: "🇳🇿"}, 77 - "mk": {Code: "mk", Name: "Macedonian", Flag: "🇲🇰"}, 78 - "ml": {Code: "ml", Name: "Malayalam", Flag: "🇮🇳"}, 79 - "mn": {Code: "mn", Name: "Mongolian", Flag: "🇲🇳"}, 80 - "mo": {Code: "mo", Name: "Moldavian", Flag: "🇲🇩"}, 81 - "mr": {Code: "mr", Name: "Marathi", Flag: "🇮🇳"}, 82 - "ms": {Code: "ms", Name: "Malay", Flag: "🇲🇾"}, 83 - "mt": {Code: "mt", Name: "Maltese", Flag: "🇲🇹"}, 84 - "my": {Code: "my", Name: "Burmese", Flag: "🇲🇲"}, 85 - "na": {Code: "na", Name: "Nauru", Flag: "🇳🇷"}, 86 - "ne": {Code: "ne", Name: "Nepali", Flag: "🇳🇵"}, 87 - "nl": {Code: "nl", Name: "Dutch", NativeName: ToPtr("Nederlands"), Flag: "🇳🇱"}, 88 - "no": {Code: "no", Name: "Norwegian", NativeName: ToPtr("Norsk"), Flag: "🇳🇴"}, 89 - "oc": {Code: "oc", Name: "Occitan", Flag: "🇫🇷"}, 90 - "om": {Code: "om", Name: "Oromo (Afan)", Flag: "🇪🇹"}, 91 - "or": {Code: "or", Name: "Oriya", Flag: "🇮🇳"}, 92 - "pa": {Code: "pa", Name: "Punjabi", Flag: "🇮🇳"}, 93 - "pl": {Code: "pl", Name: "Polish", NativeName: ToPtr("Polski"), Flag: "🇵🇱"}, 94 - "ps": {Code: "ps", Name: "Pashto (Pushto)", Flag: "🇦🇫"}, 95 - "pt": {Code: "pt", Name: "Portuguese", NativeName: ToPtr("Português"), Flag: "🇵🇹"}, 96 - "qu": {Code: "qu", Name: "Quechua", Flag: "🇵🇪"}, 97 - "rm": {Code: "rm", Name: "Rhaeto-Romance", Flag: "🇨🇭"}, 98 - "rn": {Code: "rn", Name: "Kirundi", Flag: "🇧🇮"}, 99 - "ro": {Code: "ro", Name: "Romanian", Flag: "🇷🇴"}, 100 - "ru": {Code: "ru", Name: "Russian", NativeName: ToPtr("Русский"), Flag: "🇷🇺"}, 101 - "rw": {Code: "rw", Name: "Kinyarwanda", Flag: "🇷🇼"}, 102 - "sa": {Code: "sa", Name: "Sanskrit", Flag: "🇮🇳"}, 103 - "sd": {Code: "sd", Name: "Sindhi", Flag: "🇵🇰"}, 104 - "sg": {Code: "sg", Name: "Sangro", Flag: "🇨🇫"}, 105 - "sh": {Code: "sh", Name: "Serbo-Croatian", Flag: "🇷🇸"}, 106 - "si": {Code: "si", Name: "Singhalese", Flag: "🇱🇰"}, 107 - "sk": {Code: "sk", Name: "Slovak", Flag: "🇸🇰"}, 108 - "sl": {Code: "sl", Name: "Slovenian", Flag: "🇸🇮"}, 109 - "sm": {Code: "sm", Name: "Samoan", Flag: "🇼🇸"}, 110 - "sn": {Code: "sn", Name: "Shona", Flag: "🇿🇼"}, 111 - "so": {Code: "so", Name: "Somali", Flag: "🇸🇴"}, 112 - "sq": {Code: "sq", Name: "Albanian", Flag: "🇦🇱"}, 113 - "sr": {Code: "sr", Name: "Serbian", Flag: "🇷🇸"}, 114 - "ss": {Code: "ss", Name: "Siswati", Flag: "🇸🇿"}, 115 - "st": {Code: "st", Name: "Sesotho", Flag: "🇱🇸"}, 116 - "su": {Code: "su", Name: "Sundanese", Flag: "🇮🇩"}, 117 - "sv": {Code: "sv", Name: "Swedish", NativeName: ToPtr("Svenska"), Flag: "🇸🇪"}, 118 - "sw": {Code: "sw", Name: "Swahili", Flag: "🇹🇿"}, 119 - "ta": {Code: "ta", Name: "Tamil", Flag: "🇮🇳"}, 120 - "te": {Code: "te", Name: "Tegulu", Flag: "🇮🇳"}, 121 - "tg": {Code: "tg", Name: "Tajik", Flag: "🇹🇯"}, 122 - "th": {Code: "th", Name: "Thai", NativeName: ToPtr("ภาษาไทย"), Flag: "🇹🇭"}, 123 - "ti": {Code: "ti", Name: "Tigrinya", Flag: "🇪🇷"}, 124 - "tk": {Code: "tk", Name: "Turkmen", Flag: "🇹🇲"}, 125 - "tl": {Code: "tl", Name: "Tagalog", Flag: "🇵🇭"}, 126 - "tn": {Code: "tn", Name: "Setswana", Flag: "🇧🇼"}, 127 - "to": {Code: "to", Name: "Tonga", Flag: "🇹🇴"}, 128 - "tr": {Code: "tr", Name: "Turkish", NativeName: ToPtr("Türkçe"), Flag: "🇹🇷"}, 129 - "ts": {Code: "ts", Name: "Tsonga", Flag: "🇿🇦"}, 130 - "tt": {Code: "tt", Name: "Tatar", Flag: "🇷🇺"}, 131 - "tw": {Code: "tw", Name: "Twi", Flag: "🇬🇭"}, 132 - "ug": {Code: "ug", Name: "Uigur", Flag: "🇨🇳"}, 133 - "uk": {Code: "uk", Name: "Ukrainian", Flag: "🇺🇦"}, 134 - "ur": {Code: "ur", Name: "Urdu", Flag: "🇵🇰"}, 135 - "uz": {Code: "uz", Name: "Uzbek", Flag: "🇺🇿"}, 136 - "vi": {Code: "vi", Name: "Vietnamese", NativeName: ToPtr("Tiếng Việt"), Flag: "🇻🇳"}, 137 - "vo": {Code: "vo", Name: "Volapuk", Flag: "🌍"}, 138 - "wo": {Code: "wo", Name: "Wolof", Flag: "🇸🇳"}, 139 - "xh": {Code: "xh", Name: "Xhosa", Flag: "🇿🇦"}, 140 - "yi": {Code: "yi", Name: "Yiddish", Flag: "🌍"}, 141 - "yo": {Code: "yo", Name: "Yoruba", Flag: "🇳🇬"}, 142 - "za": {Code: "za", Name: "Zhuang", Flag: "🇨🇳"}, 143 - "zh": {Code: "zh", Name: "Chinese", NativeName: ToPtr("中文"), Flag: "🇨🇳"}, 144 - "zu": {Code: "zu", Name: "Zulu", Flag: "🇿🇦"}, 6 + iso639_3 "github.com/barbashov/iso639-3" 7 + "github.com/sahilm/fuzzy" 8 + "golang.org/x/text/language" 9 + "golang.org/x/text/language/display" 10 + 11 + "yoten.app/internal/domain" 12 + ) 13 + 14 + func LookupLanguage(code domain.LanguageCode) (domain.Language, bool) { 15 + lang := iso639_3.FromAnyCode(string(code)) 16 + if lang == nil { 17 + return domain.Language{}, false 18 + } 19 + return buildLanguage(*lang), true 20 + } 21 + 22 + func AllPart1Languages() []domain.Language { 23 + languages := make([]domain.Language, 0, len(iso639_3.LanguagesPart1)) 24 + for _, lang := range iso639_3.LanguagesPart1 { 25 + languages = append(languages, buildLanguage(lang)) 26 + } 27 + 28 + sort.Slice(languages, func(i, j int) bool { 29 + return languages[i].Name < languages[j].Name 30 + }) 31 + 32 + return languages 33 + } 34 + 35 + func AllPart3Languages() []domain.Language { 36 + languages := make([]domain.Language, 0, len(iso639_3.LanguagesPart3)) 37 + for _, lang := range iso639_3.LanguagesPart3 { 38 + languages = append(languages, buildLanguage(lang)) 39 + } 40 + 41 + return languages 42 + } 43 + 44 + type languageSource []domain.Language 45 + 46 + func (s languageSource) String(i int) string { return s[i].Name } 47 + func (s languageSource) Len() int { return len(s) } 48 + 49 + func SearchLanguages(all []domain.Language, query string, limit int) []domain.Language { 50 + matches := fuzzy.FindFrom(query, languageSource(all)) 51 + 52 + if len(matches) > limit { 53 + matches = matches[:limit] 54 + } 55 + 56 + results := make([]domain.Language, len(matches)) 57 + for i, m := range matches { 58 + results[i] = all[m.Index] 59 + } 60 + return results 61 + } 62 + 63 + func buildLanguage(lang iso639_3.Language) domain.Language { 64 + // prefer part1 (iso 639-1) for bcp 47 tag resolution as x/text has better 65 + // coverage for these. fall back to part3 for languages without a 639-1 66 + // code. 67 + code := lang.Part1 68 + if code == "" { 69 + code = lang.Part3 70 + } 71 + 72 + var nativeName *string 73 + englishName := lang.Name 74 + 75 + tag, err := language.Parse(code) 76 + if err == nil { 77 + englishName = resolveEnglishName(code, lang.Name, tag) 78 + nativeName = resolveNativeName(code, englishName, tag) 79 + } 80 + 81 + return domain.Language{ 82 + Code: domain.LanguageCode(code), 83 + Name: englishName, 84 + NativeName: nativeName, 85 + } 86 + } 87 + 88 + func resolveEnglishName(code string, fallback string, tag language.Tag) string { 89 + name := display.English.Languages().Name(tag) 90 + 91 + // x/text returns the raw BCP 47 tag string when it doesn't recognise a 92 + // language. detect this and fall back to the barbashov name. 93 + if name == "" || name == code { 94 + return fallback 95 + } 96 + 97 + return name 98 + } 99 + 100 + func resolveNativeName(code string, englishName string, tag language.Tag) *string { 101 + name := display.Self.Name(tag) 102 + if name == "" || name == englishName || name == code { 103 + return nil 104 + } 105 + 106 + return &name 145 107 }
+4 -4
internal/db/profile.go
··· 94 94 return nil, fmt.Errorf("failed to scan language code: %w", err) 95 95 } 96 96 97 - language, ok := Languages[domain.LanguageCode(langCode)] 97 + language, ok := LookupLanguage(domain.LanguageCode(langCode)) 98 98 if !ok { 99 - log.Printf("language code '%s' found in db but not in global map", langCode) 99 + log.Printf("language code '%s' found in db but not recognised", langCode) 100 100 continue 101 101 } 102 102 languages = append(languages, language) ··· 163 163 } 164 164 165 165 for _, l := range profile.Languages { 166 - if len(l.Code) != 2 { 166 + if len(l.Code) < 2 || len(l.Code) > 3 { 167 167 return ErrProfileInvalidLanguageCode 168 168 } 169 169 } ··· 224 224 return nil, fmt.Errorf("failed to scan profile language: %w", err) 225 225 } 226 226 if p, ok := profileMap[did]; ok { 227 - if lang, langOK := Languages[domain.LanguageCode(langCode)]; langOK { 227 + if lang, langOK := LookupLanguage(domain.LanguageCode(langCode)); langOK { 228 228 p.Languages = append(p.Languages, lang) 229 229 profileMap[did] = p 230 230 }
+1 -1
internal/db/study-session.go
··· 190 190 } 191 191 session.Duration = time.Duration(duration) * time.Second 192 192 var ok bool 193 - session.Language, ok = Languages[domain.LanguageCode(langCode)] 193 + session.Language, ok = LookupLanguage(domain.LanguageCode(langCode)) 194 194 if !ok { 195 195 return domain.StudySession{}, 0, fmt.Errorf("failed to find language '%s'", langCode) 196 196 }
+2 -4
internal/domain/language.go
··· 1 1 package domain 2 2 3 - // An ISO 639-1 two-letter language code (e.g., 'en', 'es', 'ko') 3 + // An ISO 639-1, 639-2, or 639-3 language code (e.g., 'en', 'eng', 'asl') 4 4 type LanguageCode string 5 5 6 6 type Language struct { 7 7 Code LanguageCode 8 - // The common name, typically in English. 8 + // The common name in English. 9 9 Name string 10 10 // The name in its own language (e.g., "日本語"). 11 11 NativeName *string 12 - // An emoji. 13 - Flag string 14 12 }
+24
internal/server/handlers/language.go
··· 1 + package handlers 2 + 3 + import ( 4 + "net/http" 5 + 6 + "yoten.app/internal/db" 7 + "yoten.app/internal/domain" 8 + languagepicker "yoten.app/internal/server/ui/components/language-picker" 9 + ) 10 + 11 + func (h *Handler) HandleLanguageSearch(w http.ResponseWriter, r *http.Request) { 12 + query := r.URL.Query().Get("q") 13 + 14 + var languages []domain.Language 15 + if query == "" { 16 + languages = h.ComputedData.SortedPart1Languages 17 + } else { 18 + languages = db.SearchLanguages(h.ComputedData.Part3Languages, query, 50) 19 + } 20 + 21 + languagepicker.LanguagePickerItems(languagepicker.LanguagePickerItemsParams{ 22 + Languages: languages, 23 + }).Render(r.Context(), w) 24 + }
+5 -8
internal/server/handlers/profile.go
··· 43 43 description := r.FormValue("description") 44 44 location := r.FormValue("location") 45 45 46 - languageCodes := r.Form["languages[]"] 46 + languageCodes := r.Form["selected"] 47 47 var languages []domain.Language 48 48 for _, code := range languageCodes { 49 - language, ok := db.Languages[domain.LanguageCode(code)] 49 + language, ok := db.LookupLanguage(domain.LanguageCode(code)) 50 50 if !ok { 51 51 logger.Warn("invalid language code submitted", "languageCode", code) 52 52 continue ··· 208 208 209 209 switch r.Method { 210 210 case http.MethodGet: 211 - profileLanguageCodes := utils.Map(profile.Languages, func(language domain.Language) string { 212 - return string(language.Code) 213 - }) 214 - 215 211 editprofile.EditProfilePage(editprofile.EditProfilePageParams{ 216 212 Profile: profile, 217 213 User: user, 218 - AllLanguages: h.ComputedData.SortedLanguages, 219 - InitialSelectedLanguages: profileLanguageCodes, 214 + AllPart1Languages: h.ComputedData.SortedPart1Languages, 215 + InitialSelectedLanguages: profile.Languages, 220 216 }).Render(r.Context(), w) 221 217 case http.MethodPost: 222 218 client, err := h.Oauth.AuthorizedClient(r) ··· 232 228 htmx.HxError(w, http.StatusBadRequest, "Failed to update profile, ensure all fields contain valid data.") 233 229 return 234 230 } 231 + 235 232 updatedProfile.ID = profile.ID 236 233 updatedProfile.DID = profile.DID 237 234 updatedProfile.CreatedAt = profile.CreatedAt
+5
internal/server/handlers/router.go
··· 97 97 r.Get("/notifications", h.HandleNotificationsPage) 98 98 }) 99 99 100 + r.Route("/language", func(r chi.Router) { 101 + r.Use(middleware.AuthMiddleware(h.Oauth)) 102 + r.Get("/search", h.HandleLanguageSearch) 103 + }) 104 + 100 105 r.Route("/follow", func(r chi.Router) { 101 106 r.Use(middleware.AuthMiddleware(h.Oauth)) 102 107 r.Post("/", h.HandleFollow)
+1 -1
internal/server/handlers/study-session.go
··· 39 39 } 40 40 41 41 languageCode := r.FormValue("language_code") 42 - language, ok := db.Languages[domain.LanguageCode(languageCode)] 42 + language, ok := db.LookupLanguage(domain.LanguageCode(languageCode)) 43 43 if !ok { 44 44 return domain.StudySession{}, fmt.Errorf("failed to find language '%s'", language.Code) 45 45 }
+12 -15
internal/server/server-state.go
··· 24 24 ) 25 25 26 26 type ComputedData struct { 27 - SortedLanguages []domain.Language 28 - SortedCategories []domain.Category 29 - SortedResourceTypes []domain.ResourceType 30 - SortedReactions []domain.Reaction 27 + SortedPart1Languages []domain.Language 28 + Part3Languages []domain.Language 29 + SortedCategories []domain.Category 30 + SortedResourceTypes []domain.ResourceType 31 + SortedReactions []domain.Reaction 31 32 } 32 33 33 34 type ServerState struct { ··· 138 139 } 139 140 140 141 func computeData() ComputedData { 141 - sortedLanguages := make([]domain.Language, 0, len(db.Languages)) 142 - for _, l := range db.Languages { 143 - sortedLanguages = append(sortedLanguages, l) 144 - } 145 - slices.SortFunc(sortedLanguages, func(a, b domain.Language) int { 146 - return strings.Compare(a.Name, b.Name) 147 - }) 142 + sortedPart1Languages := db.AllPart1Languages() 143 + part3Languages := db.AllPart3Languages() 148 144 149 145 sortedCategories := make([]domain.Category, 0, len(domain.Categories)) 150 146 for _, c := range domain.Categories { ··· 171 167 }) 172 168 173 169 return ComputedData{ 174 - SortedLanguages: sortedLanguages, 175 - SortedCategories: sortedCategories, 176 - SortedResourceTypes: sortedResourceTypes, 177 - SortedReactions: sortedReactions, 170 + SortedPart1Languages: sortedPart1Languages, 171 + Part3Languages: part3Languages, 172 + SortedCategories: sortedCategories, 173 + SortedResourceTypes: sortedResourceTypes, 174 + SortedReactions: sortedReactions, 178 175 } 179 176 }
+7
internal/server/ui/components/language-picker/language-picker.go
··· 1 + package languagepicker 2 + 3 + import "yoten.app/internal/domain" 4 + 5 + type LanguagePickerItemsParams struct { 6 + Languages []domain.Language 7 + }
+35
internal/server/ui/components/language-picker/language-picker.templ
··· 1 + package languagepicker 2 + 3 + import "fmt" 4 + 5 + templ LanguagePickerItems(params LanguagePickerItemsParams) { 6 + if len(params.Languages) == 0 { 7 + <div class="col-span-2 py-6 text-center text-text-muted text-sm"> 8 + No languages match that query. 9 + </div> 10 + } else { 11 + for _, l := range params.Languages { 12 + {{ languageJSON, _ := templ.JSONString(l) }} 13 + <div 14 + key={ string(l.Code) } 15 + @click={ fmt.Sprintf("toggle(%s)", languageJSON) } 16 + :class={ fmt.Sprintf("isSelected('%s') ? 'btn-primary' : 'btn-secondary'", string(l.Code)) } 17 + class="input flex items-center gap-3 cursor-pointer" 18 + > 19 + <input 20 + type="checkbox" 21 + value={ string(l.Code) } 22 + :checked={ fmt.Sprintf("isSelected('%s')", string(l.Code)) } 23 + :disabled={ fmt.Sprintf("!isSelected('%s') && selected.length >= 10", string(l.Code)) } 24 + class="pointer-events-none" 25 + /> 26 + <label class="pointer-events-none"> 27 + <span>{ l.Name }</span> 28 + if l.NativeName != nil { 29 + <span class="text-text-muted">({ *l.NativeName })</span> 30 + } 31 + </label> 32 + </div> 33 + } 34 + } 35 + }
+1 -1
internal/server/ui/components/profile-card/profile-card.templ
··· 37 37 <p class="text-sm font-medium">Currently Learning:</p> 38 38 <div class="flex items-center gap-1"> 39 39 for _, language := range params.Profile.Languages { 40 - <span title={ language.Name }>{ language.Flag }</span> 40 + <span>{ language.Name }</span> 41 41 } 42 42 </div> 43 43 </div>
-1
internal/server/ui/components/study-session/study-session.templ
··· 80 80 </div> 81 81 <div class="flex gap-4"> 82 82 <div class="pill pill-primary w-fit"> 83 - { params.StudySession.Language.Flag } 84 83 { params.StudySession.Language.Name } 85 84 if params.StudySession.Language.NativeName != nil { 86 85 ({ *params.StudySession.Language.NativeName })
+2 -2
internal/server/ui/views/edit-profile/edit-profile.go
··· 8 8 // The current logged in user. 9 9 User *domain.User 10 10 Profile domain.Profile 11 - AllLanguages []domain.Language 12 - InitialSelectedLanguages []string 11 + AllPart1Languages []domain.Language 12 + InitialSelectedLanguages []domain.Language 13 13 }
+28 -28
internal/server/ui/views/edit-profile/edit-profile.js
··· 1 - function languagePicker(allLanguages, initialSelectedLanguages) { 2 - return { 3 - search: '', 4 - all: allLanguages || [], 5 - selected: initialSelectedLanguages || [], 6 - 7 - get selectedObjects() { 8 - return this.all.filter(lang => this.selected.includes(lang.Code)); 9 - }, 1 + /** 2 + * @typedef {object} Language 3 + * @property {string} Code 4 + * @property {string} Name 5 + * @property {string | null} NativeName 6 + */ 10 7 11 - get filteredLanguages() { 12 - if (!this.search) return this.all; 13 - return this.all.filter(lang => 14 - lang.Name.toLowerCase().includes(this.search.toLowerCase()) 15 - ); 16 - }, 8 + function languagePicker(initialSelectedLanguages) { 9 + return { 10 + /** @type{Language[]} */ 11 + selected: initialSelectedLanguages || [], 17 12 18 - isSelected(code) { 19 - return this.selected.includes(code); 20 - }, 13 + isSelected(code) { 14 + const codes = this.selected.map(l => l.Code); 15 + return codes.includes(code); 16 + }, 21 17 22 - toggle(code) { 23 - if (this.isSelected(code)) { 24 - this.selected = this.selected.filter(c => c !== code); 25 - } else { 26 - if (this.selected.length < 10) { 27 - this.selected.push(code); 28 - } 29 - } 30 - } 31 - } 18 + /** 19 + * @param {Language} language 20 + */ 21 + toggle(language) { 22 + if (this.isSelected(language.Code)) { 23 + this.selected = this.selected.filter(c => c.Code !== language.Code); 24 + } else { 25 + if (this.selected.length >= 10) { 26 + return; 27 + } 28 + this.selected.push(language); 29 + } 30 + } 31 + } 32 32 }
+27 -32
internal/server/ui/views/edit-profile/edit-profile.templ
··· 3 3 import ( 4 4 "fmt" 5 5 6 - "yoten.app/internal/db" 7 6 "yoten.app/internal/domain" 8 7 "yoten.app/internal/server/ui/components/header" 8 + languagepicker "yoten.app/internal/server/ui/components/language-picker" 9 9 "yoten.app/internal/server/ui/layouts/base" 10 10 ) 11 11 12 12 templ EditProfilePage(params EditProfilePageParams) { 13 13 {{ 14 - serializedAllLanguages, _ := templ.JSONString(params.AllLanguages) 15 14 serializedInitialSelectedLanguages, _ := templ.JSONString(params.InitialSelectedLanguages) 16 15 }} 17 16 @layouts.Base(layouts.BaseParams{Title: "edit profile"}) { ··· 87 86 </div> 88 87 </div> 89 88 <div 89 + id="language-picker" 90 90 class="card" 91 - x-data={ fmt.Sprintf("languagePicker(%s, %s)", serializedAllLanguages, serializedInitialSelectedLanguages) } 91 + x-data={ fmt.Sprintf("languagePicker(%s)", serializedInitialSelectedLanguages) } 92 92 > 93 93 <div class="flex flex-col sm:flex-row gap-2 justify-between sm:items-center"> 94 94 <h1 class="text-2xl font-bold">Languages You're Learning</h1> ··· 101 101 </p> 102 102 <div class="relative mt-4"> 103 103 <i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted"></i> 104 - <input type="text" x-model.debounce.300ms="search" placeholder="Search languages..." class="input w-full pl-9"/> 104 + <input 105 + type="text" 106 + placeholder="Search languages..." 107 + class="input w-full pl-9" 108 + hx-get="/language/search" 109 + hx-trigger="input changed delay:300ms" 110 + hx-target="#language-list" 111 + hx-swap="innerHTML" 112 + hx-include="#language-picker" 113 + name="q" 114 + /> 105 115 </div> 116 + <template x-for="lang in selected" :key="lang.Code"> 117 + <input type="hidden" name="selected" :value="lang.Code"/> 118 + </template> 106 119 <div x-show="selected.length > 0" class="mt-4"> 107 120 <h3 class="text-sm font-medium mb-2">Selected Languages:</h3> 108 121 <div class="flex flex-wrap gap-2"> 109 - <template x-for="lang in selectedObjects" :key="lang.Code"> 122 + <template x-for="lang in selected" :key="lang.Code"> 110 123 <div class="pill pill-primary flex items-center justify-center gap-2" x-init="lucide.createIcons()"> 111 - <span x-text="lang.Flag + ' ' + lang.Name"></span> 112 - <button type="button" @click="toggle(lang.Code)" class="hover:text-text hover:cursor-pointer"> 124 + <span x-text="lang.Name"></span> 125 + <button type="button" @click.stop="toggle(lang)" class="hover:text-text hover:cursor-pointer"> 113 126 <i class="w-4 h-4" data-lucide="x"></i> 114 127 </button> 115 128 </div> 116 129 </template> 117 130 </div> 118 131 </div> 119 - <div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-4 border-t pt-4 max-h-72 overflow-y-auto"> 120 - for _, l := range params.AllLanguages { 121 - <div 122 - key={ l.Code } 123 - x-show={ fmt.Sprintf("search === '' || '%s'.toLowerCase().includes(search.toLowerCase())", db.Languages[l.Code].Name) } 124 - @click={ fmt.Sprintf("toggle('%s')", l.Code) } 125 - :class={ fmt.Sprintf("isSelected('%s') ? 'btn-primary' : 'btn-secondary'", l.Code) } 126 - class="input flex items-center gap-3 cursor-pointer" 127 - > 128 - <input 129 - type="checkbox" 130 - name="languages[]" 131 - value={ l.Code } 132 - :checked={ fmt.Sprintf("isSelected('%s')", l.Code) } 133 - :disabled={ fmt.Sprintf("!isSelected('%s') && selected.length >= 10", l.Code) } 134 - class="pointer-events-none" 135 - /> 136 - <label class="pointer-events-none"> 137 - <span>{ l.Flag } { l.Name }</span> 138 - if l.NativeName != nil { 139 - <span class="text-text-muted">({ *l.NativeName })</span> 140 - } 141 - </label> 142 - </div> 143 - } 132 + <div id="language-list" class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-4 border-t pt-4 max-h-72 overflow-y-auto"> 133 + @languagepicker.LanguagePickerItems(languagepicker.LanguagePickerItemsParams{ 134 + Languages: params.AllPart1Languages, 135 + }) 144 136 </div> 137 + <p class="text-xs text-text-muted mt-2"> 138 + Showing common languages. Search to find all languages including sign languages. 139 + </p> 145 140 </div> 146 141 <div class="flex flex-col sm:flex-row gap-4 w-full"> 147 142 <button id="save-button" type="submit" class="btn btn-primary">
+1 -8
internal/server/ui/views/profile/profile.templ
··· 87 87 } 88 88 </div> 89 89 <div class="flex flex-col gap-3 mt-4"> 90 - <div> 91 - <p class="text-sm font-medium">Currently Learning:</p> 92 - <div class="flex items-center gap-1"> 93 - for _, language := range params.Profile.Languages { 94 - <span>{ language.Flag }</span> 95 - } 96 - </div> 97 - </div> 90 + <p class="text-sm font-medium">Currently Learning:</p> 98 91 <div class="flex flex-wrap gap-2"> 99 92 for _, language := range params.Profile.Languages[:min(4, len(params.Profile.Languages))] { 100 93 <div class="pill pill-primary">
+2 -2
lexicons/feed/session.json
··· 28 28 }, 29 29 "languageCode": { 30 30 "type": "string", 31 - "description": "An ISO 639-1 two-letter language code (e.g., 'en', 'es', 'ko').", 31 + "description": "An ISO 639-1, 639-2, or 639-3 language code (2–3 characters, e.g., 'en', 'eng', 'asl').", 32 32 "minLength": 2, 33 - "maxLength": 2 33 + "maxLength": 3 34 34 }, 35 35 "duration": { 36 36 "type": "integer",