Browse Source

feat(user): persists prefer language in db #1155

Jacky 4 days ago
parent
commit
1dbb852a57

+ 29 - 1
api/user/current_user.go

@@ -18,11 +18,13 @@ func GetCurrentUser(c *gin.Context) {
 func UpdateCurrentUser(c *gin.Context) {
 func UpdateCurrentUser(c *gin.Context) {
 	cosy.Core[model.User](c).
 	cosy.Core[model.User](c).
 		SetValidRules(gin.H{
 		SetValidRules(gin.H{
-			"name": "required",
+			"name":     "omitempty",
+			"language": "omitempty",
 		}).
 		}).
 		Custom(func(c *cosy.Ctx[model.User]) {
 		Custom(func(c *cosy.Ctx[model.User]) {
 			user := api.CurrentUser(c.Context)
 			user := api.CurrentUser(c.Context)
 			user.Name = c.Model.Name
 			user.Name = c.Model.Name
+			user.Language = c.Model.Language
 
 
 			db := cosy.UseDB()
 			db := cosy.UseDB()
 			err := db.Where("id = ?", user.ID).Updates(user).Error
 			err := db.Where("id = ?", user.ID).Updates(user).Error
@@ -72,3 +74,29 @@ func UpdateCurrentUserPassword(c *gin.Context) {
 		"message": "ok",
 		"message": "ok",
 	})
 	})
 }
 }
+
+func UpdateCurrentUserLanguage(c *gin.Context) {
+	var json struct {
+		Language string `json:"language" binding:"required"`
+	}
+
+	if !cosy.BindAndValid(c, &json) {
+		return
+	}
+
+	user := api.CurrentUser(c)
+	user.Language = json.Language
+
+	db := cosy.UseDB()
+	err := db.Where("id = ?", user.ID).Updates(&model.User{
+		Language: json.Language,
+	}).Error
+	if err != nil {
+		cosy.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"language": json.Language,
+	})
+}

+ 1 - 0
api/user/router.go

@@ -46,4 +46,5 @@ func InitUserRouter(r *gin.RouterGroup) {
 	r.GET("/user", GetCurrentUser)
 	r.GET("/user", GetCurrentUser)
 	r.POST("/user", middleware.RequireSecureSession(), UpdateCurrentUser)
 	r.POST("/user", middleware.RequireSecureSession(), UpdateCurrentUser)
 	r.POST("/user/password", middleware.RequireSecureSession(), UpdateCurrentUserPassword)
 	r.POST("/user/password", middleware.RequireSecureSession(), UpdateCurrentUserPassword)
+	r.POST("/user/language", UpdateCurrentUserLanguage)
 }
 }

+ 5 - 1
app/src/api/user.ts

@@ -6,18 +6,22 @@ export interface User extends ModelBase {
   password: string
   password: string
   enabled_2fa: boolean
   enabled_2fa: boolean
   status: boolean
   status: boolean
+  language: string
 }
 }
 
 
 const user = extendCurdApi(useCurdApi<User>('/users'), {
 const user = extendCurdApi(useCurdApi<User>('/users'), {
   getCurrentUser: () => {
   getCurrentUser: () => {
     return http.get('/user')
     return http.get('/user')
   },
   },
-  updateCurrentUser: (data: User) => {
+  updateCurrentUser: (data: Partial<User>) => {
     return http.post('/user', data)
     return http.post('/user', data)
   },
   },
   updateCurrentUserPassword: (data: { old_password: string, new_password: string }) => {
   updateCurrentUserPassword: (data: { old_password: string, new_password: string }) => {
     return http.post('/user/password', data)
     return http.post('/user/password', data)
   },
   },
+  updateCurrentUserLanguage: (data: { language: string }) => {
+    return http.post('/user/language', data)
+  },
 })
 })
 
 
 export default user
 export default user

+ 73 - 58
app/src/components/SetLanguage/SetLanguage.vue

@@ -2,23 +2,11 @@
 import dayjs from 'dayjs'
 import dayjs from 'dayjs'
 import loadTranslations from '@/api/translations'
 import loadTranslations from '@/api/translations'
 import gettext from '@/gettext'
 import gettext from '@/gettext'
-import { useSettingsStore } from '@/pinia'
-
-import 'dayjs/locale/fr'
-import 'dayjs/locale/ja'
-import 'dayjs/locale/ko'
-import 'dayjs/locale/de'
-import 'dayjs/locale/zh-cn'
-import 'dayjs/locale/zh-tw'
-import 'dayjs/locale/pt'
-import 'dayjs/locale/es'
-import 'dayjs/locale/it'
-import 'dayjs/locale/ar'
-import 'dayjs/locale/ru'
-import 'dayjs/locale/tr'
-import 'dayjs/locale/vi'
+import { useSettingsStore, useUserStore } from '@/pinia'
 
 
 const settings = useSettingsStore()
 const settings = useSettingsStore()
+const userStore = useUserStore()
+const { info } = storeToRefs(userStore)
 
 
 const route = useRoute()
 const route = useRoute()
 
 
@@ -43,6 +31,7 @@ watch(current, v => {
   loadTranslations(route)
   loadTranslations(route)
   settings.set_language(v)
   settings.set_language(v)
   gettext.current = v
   gettext.current = v
+  userStore.updateCurrentUserLanguage(v)
 
 
   updateTitle()
   updateTitle()
 })
 })
@@ -51,54 +40,80 @@ onMounted(() => {
   updateTitle()
   updateTitle()
 })
 })
 
 
-function init() {
-  switch (current.value) {
-    case 'fr':
-      dayjs.locale('fr')
-      break
-    case 'ja':
-      dayjs.locale('ja')
-      break
-    case 'ko':
-      dayjs.locale('ko')
-      break
-    case 'de':
-      dayjs.locale('de')
-      break
-    case 'zh_CN':
-      dayjs.locale('zh-cn')
-      break
-    case 'zh_TW':
-      dayjs.locale('zh-tw')
-      break
-    case 'pt':
-      dayjs.locale('pt')
-      break
-    case 'es':
-      dayjs.locale('es')
-      break
-    case 'it':
-      dayjs.locale('it')
-      break
-    case 'ar':
-      dayjs.locale('ar')
-      break
-    case 'ru':
-      dayjs.locale('ru')
-      break
-    case 'tr':
-      dayjs.locale('tr')
-      break
-    case 'vi':
-      dayjs.locale('vi')
-      break
-    default:
+// Language mapping configuration
+const localeMap: Record<string, string> = {
+  fr: 'fr',
+  ja: 'ja',
+  ko: 'ko',
+  de: 'de',
+  zh_CN: 'zh-cn',
+  zh_TW: 'zh-tw',
+  pt: 'pt',
+  es: 'es',
+  it: 'it',
+  ar: 'ar',
+  ru: 'ru',
+  tr: 'tr',
+  vi: 'vi',
+}
+
+// Predefined locale importers for dynamic loading
+// This approach works with Vite's static analysis requirements
+const localeImporters = {
+  'fr': () => import('dayjs/locale/fr'),
+  'ja': () => import('dayjs/locale/ja'),
+  'ko': () => import('dayjs/locale/ko'),
+  'de': () => import('dayjs/locale/de'),
+  'zh-cn': () => import('dayjs/locale/zh-cn'),
+  'zh-tw': () => import('dayjs/locale/zh-tw'),
+  'pt': () => import('dayjs/locale/pt'),
+  'es': () => import('dayjs/locale/es'),
+  'it': () => import('dayjs/locale/it'),
+  'ar': () => import('dayjs/locale/ar'),
+  'ru': () => import('dayjs/locale/ru'),
+  'tr': () => import('dayjs/locale/tr'),
+  'vi': () => import('dayjs/locale/vi'),
+}
+
+// Dynamically load dayjs locale files
+async function loadDayjsLocale(locale: string) {
+  const dayjsLocale = localeMap[locale]
+
+  if (!dayjsLocale) {
+    dayjs.locale('en')
+    return
+  }
+
+  try {
+    // Use predefined importer function
+    const importer = localeImporters[dayjsLocale]
+    if (importer) {
+      await importer()
+      dayjs.locale(dayjsLocale)
+    }
+    else {
+      // Fallback to English if locale not found
       dayjs.locale('en')
       dayjs.locale('en')
+    }
+  }
+  catch (error) {
+    console.warn(`Failed to load dayjs locale: ${dayjsLocale}`, error)
+    // Graceful fallback to English
+    dayjs.locale('en')
   }
   }
 }
 }
 
 
-init()
+// Initialize current language
+async function init() {
+  await loadDayjsLocale(current.value)
+}
 
 
+// Reactive initialization and watch
+onMounted(async () => {
+  current.value = info.value.language || 'en'
+  await nextTick()
+  await init()
+})
 watch(current, init)
 watch(current, init)
 </script>
 </script>
 
 

+ 12 - 0
app/src/pinia/moudule/user.ts

@@ -87,6 +87,17 @@ export const useUserStore = defineStore('user', () => {
     }
     }
   }
   }
 
 
+  async function updateCurrentUserLanguage(language: string) {
+    try {
+      await user.updateCurrentUserLanguage({ language })
+      info.value.language = language
+    }
+    catch (error) {
+      console.error('Failed to update language:', error)
+      throw error
+    }
+  }
+
   return {
   return {
     token,
     token,
     unreadCount,
     unreadCount,
@@ -101,6 +112,7 @@ export const useUserStore = defineStore('user', () => {
     getCurrentUser,
     getCurrentUser,
     updateCurrentUser,
     updateCurrentUser,
     updateCurrentUserPassword,
     updateCurrentUserPassword,
+    updateCurrentUserLanguage,
   }
   }
 }, {
 }, {
   persist: true,
   persist: true,

+ 6 - 0
app/src/views/other/Login.vue

@@ -66,6 +66,8 @@ function onSubmit() {
           login(r.token)
           login(r.token)
           await nextTick()
           await nextTick()
           secureSessionId.value = r.secure_session_id
           secureSessionId.value = r.secure_session_id
+          await userStore.getCurrentUser()
+          await nextTick()
           await router.push(next)
           await router.push(next)
           break
           break
         case 199:
         case 199:
@@ -115,6 +117,8 @@ if (route.query?.code !== undefined && route.query?.state !== undefined) {
 
 
     const next = (route.query?.next || '').toString() || '/'
     const next = (route.query?.next || '').toString() || '/'
 
 
+    await userStore.getCurrentUser()
+    await nextTick()
     await router.push(next)
     await router.push(next)
   })
   })
   loading.value = false
   loading.value = false
@@ -150,6 +154,8 @@ async function handlePasskeyLogin() {
 
 
     passkeyLogin(asseResp.rawId, r.token)
     passkeyLogin(asseResp.rawId, r.token)
     secureSessionId.value = r.secure_session_id
     secureSessionId.value = r.secure_session_id
+    await userStore.getCurrentUser()
+    await nextTick()
     await router.push(next)
     await router.push(next)
   }
   }
 
 

+ 1 - 0
model/user.go

@@ -32,6 +32,7 @@ type User struct {
 	OTPSecret     []byte        `json:"-" gorm:"type:blob"`
 	OTPSecret     []byte        `json:"-" gorm:"type:blob"`
 	RecoveryCodes RecoveryCodes `json:"-" gorm:"serializer:json[aes]"`
 	RecoveryCodes RecoveryCodes `json:"-" gorm:"serializer:json[aes]"`
 	EnabledTwoFA  bool          `json:"enabled_2fa" gorm:"-"`
 	EnabledTwoFA  bool          `json:"enabled_2fa" gorm:"-"`
+	Language      string        `json:"language" gorm:"default:en"`
 }
 }
 
 
 type AuthToken struct {
 type AuthToken struct {

+ 5 - 1
query/users.gen.go

@@ -37,6 +37,7 @@ func newUser(db *gorm.DB, opts ...gen.DOOption) user {
 	_user.Status = field.NewBool(tableName, "status")
 	_user.Status = field.NewBool(tableName, "status")
 	_user.OTPSecret = field.NewBytes(tableName, "otp_secret")
 	_user.OTPSecret = field.NewBytes(tableName, "otp_secret")
 	_user.RecoveryCodes = field.NewField(tableName, "recovery_codes")
 	_user.RecoveryCodes = field.NewField(tableName, "recovery_codes")
+	_user.Language = field.NewString(tableName, "language")
 
 
 	_user.fillFieldMap()
 	_user.fillFieldMap()
 
 
@@ -56,6 +57,7 @@ type user struct {
 	Status        field.Bool
 	Status        field.Bool
 	OTPSecret     field.Bytes
 	OTPSecret     field.Bytes
 	RecoveryCodes field.Field
 	RecoveryCodes field.Field
+	Language      field.String
 
 
 	fieldMap map[string]field.Expr
 	fieldMap map[string]field.Expr
 }
 }
@@ -81,6 +83,7 @@ func (u *user) updateTableName(table string) *user {
 	u.Status = field.NewBool(table, "status")
 	u.Status = field.NewBool(table, "status")
 	u.OTPSecret = field.NewBytes(table, "otp_secret")
 	u.OTPSecret = field.NewBytes(table, "otp_secret")
 	u.RecoveryCodes = field.NewField(table, "recovery_codes")
 	u.RecoveryCodes = field.NewField(table, "recovery_codes")
+	u.Language = field.NewString(table, "language")
 
 
 	u.fillFieldMap()
 	u.fillFieldMap()
 
 
@@ -97,7 +100,7 @@ func (u *user) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
 }
 }
 
 
 func (u *user) fillFieldMap() {
 func (u *user) fillFieldMap() {
-	u.fieldMap = make(map[string]field.Expr, 9)
+	u.fieldMap = make(map[string]field.Expr, 10)
 	u.fieldMap["id"] = u.ID
 	u.fieldMap["id"] = u.ID
 	u.fieldMap["created_at"] = u.CreatedAt
 	u.fieldMap["created_at"] = u.CreatedAt
 	u.fieldMap["updated_at"] = u.UpdatedAt
 	u.fieldMap["updated_at"] = u.UpdatedAt
@@ -107,6 +110,7 @@ func (u *user) fillFieldMap() {
 	u.fieldMap["status"] = u.Status
 	u.fieldMap["status"] = u.Status
 	u.fieldMap["otp_secret"] = u.OTPSecret
 	u.fieldMap["otp_secret"] = u.OTPSecret
 	u.fieldMap["recovery_codes"] = u.RecoveryCodes
 	u.fieldMap["recovery_codes"] = u.RecoveryCodes
+	u.fieldMap["language"] = u.Language
 }
 }
 
 
 func (u user) clone(db *gorm.DB) user {
 func (u user) clone(db *gorm.DB) user {