Merge branch 'front-styles' of ssh://git.ma-al.com:8822/goc_daniel/b2b into translate

This commit is contained in:
2026-04-03 08:24:31 +02:00
9 changed files with 349 additions and 163 deletions

View File

@@ -8,6 +8,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"regexp"
"slices" "slices"
"strings" "strings"
"time" "time"
@@ -99,8 +100,9 @@ func (s *ProductTranslationService) SaveProductDescription(userID uint, productI
// check that fields description, description_short and usage, if they exist, have a valid html format // check that fields description, description_short and usage, if they exist, have a valid html format
mustBeHTML := []string{"description", "description_short", "usage"} mustBeHTML := []string{"description", "description_short", "usage"}
for i := 0; i < len(mustBeHTML); i++ { for i := 0; i < len(mustBeHTML); i++ {
if text, exists := updates[mustBeHTML[i]]; exists { if _, exists := updates[mustBeHTML[i]]; exists {
if !isValidXHTML(text) { updates[mustBeHTML[i]] = parseAutoCloseTags(updates[mustBeHTML[i]])
if !isValidXHTML(updates[mustBeHTML[i]]) {
return responseErrors.ErrInvalidXHTML return responseErrors.ErrInvalidXHTML
} }
} }
@@ -245,9 +247,17 @@ func cleanForPrompt(s string) string {
} }
} }
if slices.Contains(xml.HTMLAutoClose, v.Name.Local) {
prompt += "/>"
} else {
prompt += ">" prompt += ">"
}
case xml.EndElement: case xml.EndElement:
if !slices.Contains(xml.HTMLAutoClose, v.Name.Local) {
prompt += "</" + attrName(v.Name) + ">" prompt += "</" + attrName(v.Name) + ">"
}
case xml.CharData: case xml.CharData:
prompt += string(v) prompt += string(v)
case xml.Comment: case xml.Comment:
@@ -288,6 +298,43 @@ func getStringInBetween(str string, start string, end string) (success bool, res
return true, str[s : s+e] return true, str[s : s+e]
} }
// this converts input into HTML4 format.
// this really is ad-hoc solution, but it works.
func parseAutoCloseTags(s string) string {
alts := ""
for i, name := range xml.HTMLAutoClose {
if i > 0 {
alts += "|"
}
alts += name
}
// remove closing </img> tags
reClose := regexp.MustCompile(`(?i)<\s*\/\s*(?:` + alts + `)\s*>`)
s = reClose.ReplaceAllString(s, "")
// convert <img ...> → <img ... />
// matches <img ...> that do NOT already end with />
reOpen := regexp.MustCompile(`(?i)<\s*(` + alts + `)\b([^>]*?)>`)
s = reOpen.ReplaceAllStringFunc(s, func(tag string) string {
trimmed := strings.TrimSpace(tag)
// Already self-closed: <img ... />, <br/>
if strings.HasSuffix(trimmed, "/>") {
return tag
}
// Replace final > with />
i := strings.LastIndex(tag, ">")
if i < 0 {
return tag
}
return tag[:i] + " />"
})
return s
}
// isValidXHTML checks if the string obeys the XHTML format // isValidXHTML checks if the string obeys the XHTML format
func isValidXHTML(s string) bool { func isValidXHTML(s string) bool {
r := strings.NewReader(s) r := strings.NewReader(s)
@@ -363,7 +410,12 @@ func rebuildFromResponse(s_original string, s_response string) (bool, string) {
result += fmt.Sprintf(` %s="%s"`, attrName(attr.Name), attr.Value) result += fmt.Sprintf(` %s="%s"`, attrName(attr.Name), attr.Value)
} }
} }
if slices.Contains(xml.HTMLAutoClose, v_original.Name.Local) {
result += "/>"
} else {
result += ">" result += ">"
}
case xml.CharData: case xml.CharData:
result += string(v_response) result += string(v_response)
@@ -381,7 +433,7 @@ func rebuildFromResponse(s_original string, s_response string) (bool, string) {
return false, "" return false, ""
} }
if v_original.Name.Local != "img" { if !slices.Contains(xml.HTMLAutoClose, v_original.Name.Local) {
result += "</" + attrName(v_original.Name) + ">" result += "</" + attrName(v_original.Name) + ">"
} }

View File

@@ -1,25 +1,25 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "bo", "name": "bo",
"dependencies": { "dependencies": {
"@nuxt/ui": "^4.5.0", "@nuxt/ui": "^4.6.0",
"@tailwindcss/vite": "^4.2.0", "@tailwindcss/vite": "^4.2.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"tailwindcss": "^4.2.0", "reka-ui": "^2.9.3",
"tailwindcss": "^4.2.2",
"vue": "beta", "vue": "beta",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",
"vue-i18n": "11", "vue-i18n": "^11.3.0",
"vue-router": "^5.0.2", "vue-router": "^5.0.4",
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node24": "^24.0.4", "@tsconfig/node24": "^24.0.4",
"@types/node": "^24.10.13", "@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue": "^6.0.5",
"@vue/eslint-config-typescript": "^14.6.0", "@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
@@ -27,8 +27,8 @@
"prettier": "3.8.1", "prettier": "3.8.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "beta", "vite": "beta",
"vite-plugin-vue-devtools": "^8.0.6", "vite-plugin-vue-devtools": "^8.1.1",
"vue-tsc": "^3.2.4", "vue-tsc": "^3.2.6",
}, },
}, },
}, },
@@ -1012,7 +1012,7 @@
"regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="], "regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="],
"reka-ui": ["reka-ui@2.9.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.4.0" } }, "sha512-/t4e6y1hcG+uDuRfpg6tbMz3uUEvRzNco6NeYTufoJeUghy5Iosxos5YL/p+ieAsid84sdMX9OrgDqpEuCJhBw=="], "reka-ui": ["reka-ui@2.9.3", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.4.0" } }, "sha512-C9lCVxsSC7uYD0Nbgik1+14FNndHNprZmf0zGQt0ZDYIt5KxXV3zD0hEqNcfRUsEEJvVmoRsUkJnASBVBeaaUw=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
@@ -1186,6 +1186,8 @@
"@nuxt/schema/std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], "@nuxt/schema/std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
"@nuxt/ui/reka-ui": ["reka-ui@2.9.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.4.0" } }, "sha512-/t4e6y1hcG+uDuRfpg6tbMz3uUEvRzNco6NeYTufoJeUghy5Iosxos5YL/p+ieAsid84sdMX9OrgDqpEuCJhBw=="],
"@nuxtjs/color-mode/@nuxt/kit": ["@nuxt/kit@3.21.2", "", { "dependencies": { "c12": "^3.3.3", "consola": "^3.4.2", "defu": "^6.1.4", "destr": "^2.0.5", "errx": "^0.1.0", "exsolve": "^1.0.8", "ignore": "^7.0.5", "jiti": "^2.6.1", "klona": "^2.0.6", "knitwork": "^1.3.0", "mlly": "^1.8.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "rc9": "^3.0.0", "scule": "^1.3.0", "semver": "^7.7.4", "tinyglobby": "^0.2.15", "ufo": "^1.6.3", "unctx": "^2.5.0", "untyped": "^2.0.0" } }, "sha512-Bd6m6mrDrqpBEbX+g0rc66/ALd1sxlgdx5nfK9MAYO0yKLTOSK7McSYz1KcOYn3LQFCXOWfvXwaqih/b+REI1g=="], "@nuxtjs/color-mode/@nuxt/kit": ["@nuxt/kit@3.21.2", "", { "dependencies": { "c12": "^3.3.3", "consola": "^3.4.2", "defu": "^6.1.4", "destr": "^2.0.5", "errx": "^0.1.0", "exsolve": "^1.0.8", "ignore": "^7.0.5", "jiti": "^2.6.1", "klona": "^2.0.6", "knitwork": "^1.3.0", "mlly": "^1.8.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "rc9": "^3.0.0", "scule": "^1.3.0", "semver": "^7.7.4", "tinyglobby": "^0.2.15", "ufo": "^1.6.3", "unctx": "^2.5.0", "untyped": "^2.0.0" } }, "sha512-Bd6m6mrDrqpBEbX+g0rc66/ALd1sxlgdx5nfK9MAYO0yKLTOSK7McSYz1KcOYn3LQFCXOWfvXwaqih/b+REI1g=="],
"@nuxtjs/color-mode/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "@nuxtjs/color-mode/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
@@ -1254,6 +1256,8 @@
"vaul-vue/@vueuse/core": ["@vueuse/core@10.11.1", "", { "dependencies": { "@types/web-bluetooth": "^0.0.20", "@vueuse/metadata": "10.11.1", "@vueuse/shared": "10.11.1", "vue-demi": ">=0.14.8" } }, "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww=="], "vaul-vue/@vueuse/core": ["@vueuse/core@10.11.1", "", { "dependencies": { "@types/web-bluetooth": "^0.0.20", "@vueuse/metadata": "10.11.1", "@vueuse/shared": "10.11.1", "vue-demi": ">=0.14.8" } }, "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww=="],
"vaul-vue/reka-ui": ["reka-ui@2.9.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.4.0" } }, "sha512-/t4e6y1hcG+uDuRfpg6tbMz3uUEvRzNco6NeYTufoJeUghy5Iosxos5YL/p+ieAsid84sdMX9OrgDqpEuCJhBw=="],
"vue-i18n/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="], "vue-i18n/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
"vue-router/@vue/devtools-api": ["@vue/devtools-api@8.1.1", "", { "dependencies": { "@vue/devtools-kit": "^8.1.1" } }, "sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw=="], "vue-router/@vue/devtools-api": ["@vue/devtools-api@8.1.1", "", { "dependencies": { "@vue/devtools-kit": "^8.1.1" } }, "sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw=="],
@@ -1283,5 +1287,7 @@
"vaul-vue/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@10.11.1", "", {}, "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="], "vaul-vue/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@10.11.1", "", {}, "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="],
"vaul-vue/@vueuse/core/@vueuse/shared": ["@vueuse/shared@10.11.1", "", { "dependencies": { "vue-demi": ">=0.14.8" } }, "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA=="], "vaul-vue/@vueuse/core/@vueuse/shared": ["@vueuse/shared@10.11.1", "", { "dependencies": { "vue-demi": ">=0.14.8" } }, "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA=="],
"vaul-vue/reka-ui/@vueuse/core": ["@vueuse/core@14.2.1", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.2.1", "@vueuse/shared": "14.2.1" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ=="],
} }
} }

2
bo/components.d.ts vendored
View File

@@ -46,6 +46,8 @@ declare module 'vue' {
UCheckbox: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Checkbox.vue')['default'] UCheckbox: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Checkbox.vue')['default']
UDrawer: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Drawer.vue')['default'] UDrawer: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Drawer.vue')['default']
UDropdownMenu: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/DropdownMenu.vue')['default'] UDropdownMenu: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/DropdownMenu.vue')['default']
UEditor: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Editor.vue')['default']
UEditorToolbar: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/EditorToolbar.vue')['default']
UForm: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Form.vue')['default'] UForm: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Form.vue')['default']
UFormField: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default'] UFormField: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
UIcon: typeof import('./node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default'] UIcon: typeof import('./node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']

View File

@@ -17,6 +17,7 @@
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"reka-ui": "^2.9.3",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"vue": "beta", "vue": "beta",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",

View File

@@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { TooltipProvider } from 'reka-ui'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
</script> </script>
<template> <template>
<Suspense> <Suspense>
<TooltipProvider>
<RouterView /> <RouterView />
</TooltipProvider>
</Suspense> </Suspense>
</template> </template>

View File

@@ -1,9 +1,11 @@
<template> <template>
<component :is="Default || 'div'"> <component :is="Default || 'div'">
<div class="flex gap-10"> <div class="flex flex-col md:flex-row gap-10">
<CategoryMenu /> <CategoryMenu />
<div class="w-full flex flex-col items-center gap-4"> <div class="w-full flex flex-col items-center gap-4">
<UTable :data="productsList" :columns="columns" class="flex-1 w-full" /> <UTable :data="productsList" :columns="columns" class="flex-1 w-full" :ui="{
root : 'max-w-100wv overflow-auto!'
}" />
<UPagination v-model:page="page" :total="total" :items-per-page="perPage" /> <UPagination v-model:page="page" :total="total" :items-per-page="perPage" />
</div> </div>
</div> </div>

View File

@@ -7,47 +7,65 @@
</div> </div>
<div class="container mx-auto "> <div class="container mx-auto ">
<div <div
class=" gap-4 mb-6 border-b border-(--border-light) dark:border-(--border-dark) pb-6 rounded-md"> class="gap-4 mb-6 bg-slate-50 dark:bg-(--main-dark) border border-(--border-light) dark:border-(--border-dark) p-4 rounded-md">
<div v-if="!isShowProductView" class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3" v-if="!isTranslations"> <div class="flex flex-col items-center gap-3" v-if="!isTranslations">
<p class="text-md whitespace-nowrap"> <USelectMenu v-model="toLangId" :items="langs" value-key="id"
Translate from Polish to :</p>
<USelectMenu v-model="toLangId" :items="availableLangs" value-key="id" label-key="name"
class="w-48 bg-(--main-light) dark:bg-(--black) rounded-md shadow-sm" :searchInput="false"> class="w-48 bg-(--main-light) dark:bg-(--black) rounded-md shadow-sm" :searchInput="false">
<template #default> <template #default>
<div class="flex items-center gap-2"> <div class="flex flex-col items-start leading-tight">
<div class="flex flex-col leading-tight items-start">
<span class="text-xs text-gray-400"> <span class="text-xs text-gray-400">
Select language Selected language
</span> </span>
<span class="font-medium dark:text-white text-black"> <span class="font-medium dark:text-white text-black">
{{availableLangs.find(l => l.id === toLangId)?.name || 'Select language'}} {{langs.find(l => l.id === toLangId)?.name || 'Select language'}}
</span> </span>
</div> </div>
</div>
</template> </template>
<template #item-leading="{ item }"> <template #item-leading="{ item }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 cursor-pointer">
<span>{{ item.flag }}</span> <span class="text-lg">{{ item.flag }}</span>
<span class="font-medium dark:text-white text-black">{{ item.name }}</span> <span class="font-medium dark:text-white text-black">
{{ item.name }}
</span>
</div> </div>
</template> </template>
</USelectMenu> </USelectMenu>
</div> </div>
<div class="flex gap-3"> <div v-if="toLangId !== settingStore.shopDefaultLanguage && !isTranslations" class="flex gap-7">
<UButton @click="translateToSelectedLanguage" color="info" :loading="translating">
Translate from Polish to {{langs.find(l => l.id === toLangId)?.name}}
</UButton>
</div>
<div v-if="isTranslations" class="flex gap-3 w-full justify-end">
<UButton @click="() => {
toLangId = settingStore.shopDefaultLanguage
isTranslations = false
}" color="info" variant="outline">
Cancel and back to Polish
</UButton>
<UButton color="info" @click="productStore.saveProductDescription(productID, toLangId)">
Save translations
</UButton>
</div>
<!-- <div class="flex gap-7">
<div v-if="isTranslations === false">
<UButton @click="() => { <UButton @click="() => {
fetchForLanguage(toLangId) fetchForLanguage(toLangId)
isShowProductView = true isShowProductView = true
}" v-if="!isTranslations" class="text-(--text-sky-light) dark:text-(--text-sky-dark)" color="neutral" }" class="text-(--accent-blue-light) dark:text-(--accent-blue-dark)"
variant="outline"> color="neutral" variant="outline">
<p class="dark:text-white"> Show product</p> <p class="dark:text-white"> Show product</p>
</UButton> </UButton>
<UButton @click="translateToSelectedLanguage" color="primary" :loading="translating" v-if="!isTranslations" <UButton @click="translateToSelectedLanguage" color="primary" :loading="translating"
class="text-(--text-sky-light) dark:text-(--text-sky-dark) bg-sky-100 dark:bg-(--accent-blue-dark) flex items-center! justify-center!"> class="text-white bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) flex items-center! justify-center! px-12">
Translate Translate
</UButton> </UButton>
</div>
<div v-else class="flex gap-3"> <div v-else class="flex gap-3">
<UButton @click="() => { <UButton @click="() => {
toLangId = settingStore.shopDefaultLanguage toLangId = settingStore.shopDefaultLanguage
@@ -60,23 +78,16 @@
Save Save
</UButton> </UButton>
</div> </div>
</div> </div> -->
</div>
<div v-else>
<UButton @click="isShowProductView = false" color="primary"
class="text-(--text-sky-light) dark:text-(--text-sky-dark) bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) gap-2">
<UIcon name="line-md:arrow-left" class="" />
Back
</UButton>
</div> </div>
</div> </div>
<div v-if="translating" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <!-- <div v-if="translating" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="flex flex-col items-center gap-4 p-8 bg-(--main-light) dark:bg-(--main-dark) rounded-lg shadow-xl"> <div class="flex flex-col items-center gap-4 p-8 bg-(--main-light) dark:bg-(--main-dark) rounded-lg shadow-xl">
<UIcon name="svg-spinners:ring-resize" class="text-4xl text-primary" /> <UIcon name="svg-spinners:ring-resize" class="text-4xl text-primary" />
<p class="text-lg font-medium dark:text-white text-black">Translating...</p> <p class="text-lg font-medium dark:text-white text-black">Translating...</p>
</div> </div>
</div> </div> -->
<div v-if="productStore.loading" class="flex items-center justify-center py-20"> <div v-if="productStore.loading" class="flex items-center justify-center py-20">
<UIcon name="svg-spinners:ring-resize" class="text-4xl text-primary" /> <UIcon name="svg-spinners:ring-resize" class="text-4xl text-primary" />
@@ -85,7 +96,8 @@
<p class="text-red-500">{{ productStore.error }}</p> <p class="text-red-500">{{ productStore.error }}</p>
</div> </div>
<div v-else-if="productStore.productDescription" class="flex items-start gap-30"> <div v-else-if="productStore.productDescription" class="">
<div class="flex items-start gap-30">
<div class="w-80 h-80 bg-(--second-light) dark:bg-gray-700 rounded-lg flex items-center justify-center"> <div class="w-80 h-80 bg-(--second-light) dark:bg-gray-700 rounded-lg flex items-center justify-center">
<span class="text-gray-500 dark:text-gray-400">Product Image</span> <span class="text-gray-500 dark:text-gray-400">Product Image</span>
</div> </div>
@@ -99,7 +111,7 @@
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<UIcon name="lets-icons:done-ring-round-fill" <UIcon name="lets-icons:done-ring-round-fill"
class="text-[20px] light:text-(--accent-green-light) dark:text-(--accent-green-dark)" /> class="text-[20px] light:text-(--accent-green-light) dark:text-(--accent-green-dark)" />
<p class="text-[16px] font-bold text-(--text-sky-light) dark:text-(--text-sky-dark)"> <p class="text-[16px] font-bold text-(--accent-blue-light) dark:text-(--accent-blue-dark)">
{{ productStore.productDescription.available_now }} {{ productStore.productDescription.available_now }}
</p> </p>
</div> </div>
@@ -114,31 +126,32 @@
</div> </div>
</div> </div>
<div v-if="productStore.productDescription" class="mt-16">
<UTabs :items="items" v-model="activeTab" color="info" :content="true"> <UTabs :items="items" v-model="activeTab" color="info" :content="true">
<template #description> <template #description>
<div class="px-7"> <div v-if="!isTranslations" class="flex justify-end items-center gap-3 mb-4">
<div class="flex items-center justify-end gap-3 mb-4"> <UButton v-if="!isEditing" @click="activeTab === 'usage' ? enableEdit() : enableDescriptionEdit()"
<UButton v-if="!descriptionEdit.isEditing.value" @click="enableDescriptionEdit"
class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!"> class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!">
<p class="text-(--text-sky-light) dark:text-(--text-sky-dark)">Change Text</p> <p class="text-white">Change Text</p>
<UIcon name="material-symbols-light:stylus-note-sharp" class="text-[30px] text-(--text-sky-light) dark:text-(--text-sky-dark)" /> <UIcon name="material-symbols-light:stylus-note-sharp" class="text-[30px] text-white!" />
</UButton> </UButton>
<UButton v-if="isEditing" @click="activeTab === 'usage' ? saveText : saveDescription" color="neutral"
<UButton v-if="descriptionEdit.isEditing.value" @click="saveDescription" color="neutral"
variant="outline" class="p-2.5 cursor-pointer"> variant="outline" class="p-2.5 cursor-pointer">
<p class="dark:text-white text-black">Save the edited text</p> <p class="dark:text-white text-black">Save the edited text</p>
</UButton> </UButton>
<UButton v-if="isEditing" @click="activeTab === 'usage' ? cancelEdit : cancelDescriptionEdit"
<UButton v-if="descriptionEdit.isEditing.value" @click="cancelDescriptionEdit" color="neutral" color="neutral" variant="outline" class="p-2.5 cursor-pointer">
variant="outline" class="p-2.5 cursor-pointer">
Cancel Cancel
</UButton> </UButton>
</div> </div>
<UEditor v-if="isTranslations" v-slot="{ editor }" v-model="productStore.productDescription.description"
<div ref="description" v-html="productStore.productDescription.description" content-type="html" :ui="{ base: 'p-8 sm:px-16' }" class="w-full min-h-74" placeholder="Write there ...">
class="flex flex-col justify-center dark:text-white text-black"></div> <UEditorToolbar :editor="editor" :items="toolbarItems" class="sm:px-8">
</div> <template #link>
<EditorLinkPopover :editor="editor" auto-open />
</template>
</UEditorToolbar>
</UEditor>
<p v-else v-html="productStore.productDescription.description" class="text-black dark:text-white"></p>
</template> </template>
<template #usage> <template #usage>
@@ -161,8 +174,15 @@
</UButton> </UButton>
</div> </div>
<p ref="usage" v-html="productStore.productDescription.usage" <UEditor v-if="isTranslations" v-slot="{ editor }" v-model="productStore.productDescription.usage"
class="flex flex-col justify-center w-full text-start dark:text-white text-black"></p> content-type="html" :ui="{ base: 'p-8 sm:px-16' }" class="w-full min-h-74" placeholder="Write there ...">
<UEditorToolbar :editor="editor" :items="toolbarItems" class="sm:px-8">
<template #link>
<EditorLinkPopover :editor="editor" auto-open />
</template>
</UEditorToolbar>
</UEditor>
<p v-else v-html="productStore.productDescription.usage" class="text-black dark:text-white"></p>
</div> </div>
</template> </template>
</UTabs> </UTabs>
@@ -177,10 +197,141 @@ import Default from '@/layouts/default.vue';
import { langs } from '@/router/langs'; import { langs } from '@/router/langs';
import { useProductStore } from '@/stores/product'; import { useProductStore } from '@/stores/product';
import { useSettingsStore } from '@/stores/settings'; import { useSettingsStore } from '@/stores/settings';
import { onMounted, ref } from 'vue'; import type { EditorToolbarItem } from '@nuxt/ui';
import { computed } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
const toolbarItems: EditorToolbarItem[][] = [
// History controls
[{
kind: 'undo',
icon: 'i-lucide-undo',
tooltip: { text: 'Undo' }
}, {
kind: 'redo',
icon: 'i-lucide-redo',
tooltip: { text: 'Redo' }
}],
// Block types
[{
icon: 'i-lucide-heading',
tooltip: { text: 'Headings' },
content: {
align: 'start'
},
items: [{
kind: 'heading',
level: 1,
icon: 'i-lucide-heading-1',
label: 'Heading 1'
}, {
kind: 'heading',
level: 2,
icon: 'i-lucide-heading-2',
label: 'Heading 2'
}, {
kind: 'heading',
level: 3,
icon: 'i-lucide-heading-3',
label: 'Heading 3'
}, {
kind: 'heading',
level: 4,
icon: 'i-lucide-heading-4',
label: 'Heading 4'
}]
}, {
icon: 'i-lucide-list',
tooltip: { text: 'Lists' },
content: {
align: 'start'
},
items: [{
kind: 'bulletList',
icon: 'i-lucide-list',
label: 'Bullet List'
}, {
kind: 'orderedList',
icon: 'i-lucide-list-ordered',
label: 'Ordered List'
}]
}, {
kind: 'blockquote',
icon: 'i-lucide-text-quote',
tooltip: { text: 'Blockquote' }
}, {
kind: 'codeBlock',
icon: 'i-lucide-square-code',
tooltip: { text: 'Code Block' }
}, {
kind: 'horizontalRule',
icon: 'i-lucide-separator-horizontal',
tooltip: { text: 'Horizontal Rule' }
}],
// Text formatting
[{
kind: 'mark',
mark: 'bold',
icon: 'i-lucide-bold',
tooltip: { text: 'Bold' }
}, {
kind: 'mark',
mark: 'italic',
icon: 'i-lucide-italic',
tooltip: { text: 'Italic' }
}, {
kind: 'mark',
mark: 'underline',
icon: 'i-lucide-underline',
tooltip: { text: 'Underline' }
}, {
kind: 'mark',
mark: 'strike',
icon: 'i-lucide-strikethrough',
tooltip: { text: 'Strikethrough' }
}, {
kind: 'mark',
mark: 'code',
icon: 'i-lucide-code',
tooltip: { text: 'Code' }
}],
// Link
[{
kind: 'link',
icon: 'i-lucide-link',
tooltip: { text: 'Link' }
}],
// Text alignment
[{
icon: 'i-lucide-align-justify',
tooltip: { text: 'Text Align' },
content: {
align: 'end'
},
items: [{
kind: 'textAlign',
align: 'left',
icon: 'i-lucide-align-left',
label: 'Align Left'
}, {
kind: 'textAlign',
align: 'center',
icon: 'i-lucide-align-center',
label: 'Align Center'
}, {
kind: 'textAlign',
align: 'right',
icon: 'i-lucide-align-right',
label: 'Align Right'
}, {
kind: 'textAlign',
align: 'justify',
icon: 'i-lucide-align-justify',
label: 'Align Justify'
}]
}]
]
const router = useRouter() const router = useRouter()
function backFromProduct() { function backFromProduct() {
let path = localStorage.getItem('back_from_product') let path = localStorage.getItem('back_from_product')
@@ -206,7 +357,7 @@ const settingStore = useSettingsStore()
const productStore = useProductStore() const productStore = useProductStore()
const isTranslations = ref(false) const isTranslations = ref(false)
const toLangId = ref(null) const toLangId = ref<number>(settingStore.shopDefaultLanguage)
const productID = ref<number>(0) const productID = ref<number>(0)
const translating = ref(false) const translating = ref(false)
@@ -219,10 +370,6 @@ const descriptionRef = ref<HTMLElement | null>(null)
const descriptionEdit = useEditable(descriptionRef) const descriptionEdit = useEditable(descriptionRef)
const originalDescription = ref('') const originalDescription = ref('')
const isShowProductView = ref(false)
const availableLangs = computed(() => langs.filter(item => item.id !== settingStore.shopDefaultLanguage))
const translateToSelectedLanguage = async () => { const translateToSelectedLanguage = async () => {
if (toLangId.value && productID.value) { if (toLangId.value && productID.value) {
translating.value = true translating.value = true
@@ -230,6 +377,8 @@ const translateToSelectedLanguage = async () => {
await productStore.translateProductDescription(productID.value, toLangId.value) await productStore.translateProductDescription(productID.value, toLangId.value)
} finally { } finally {
translating.value = false translating.value = false
enableEdit()
enableDescriptionEdit()
} }
} }
if (productStore.productDescription) { if (productStore.productDescription) {
@@ -244,6 +393,10 @@ const fetchForLanguage = async (langId: number | null) => {
} }
} }
watch(toLangId, () => {
fetchForLanguage(toLangId.value)
})
onMounted(async () => { onMounted(async () => {
const id = route.params.product_id const id = route.params.product_id
if (id) { if (id) {
@@ -259,6 +412,8 @@ const items = ref([
// text edit // text edit
const enableEdit = () => { const enableEdit = () => {
console.log(usageRef.value, 'usageRef');
if (usageRef.value) { if (usageRef.value) {
originalUsage.value = usageRef.value.innerHTML originalUsage.value = usageRef.value.innerHTML
} }
@@ -266,6 +421,15 @@ const enableEdit = () => {
usageEdit.enableEdit() usageEdit.enableEdit()
} }
const enableDescriptionEdit = () => {
console.log(usageRef.value, 'usageRef');
if (descriptionRef.value) {
originalDescription.value = descriptionRef.value.innerHTML
}
descriptionEdit.enableEdit()
}
const saveText = () => { const saveText = () => {
if (usageRef.value) { if (usageRef.value) {
productStore.productDescription.usage = usageRef.value.innerHTML productStore.productDescription.usage = usageRef.value.innerHTML
@@ -285,13 +449,6 @@ const cancelEdit = () => {
isEditing.value = false isEditing.value = false
} }
const enableDescriptionEdit = () => {
if (descriptionRef.value) {
originalDescription.value = descriptionRef.value.innerHTML
}
descriptionEdit.enableEdit()
}
const saveDescription = async () => { const saveDescription = async () => {
if (descriptionRef.value) { if (descriptionRef.value) {
productStore.productDescription.description = descriptionRef.value.innerHTML productStore.productDescription.description = descriptionRef.value.innerHTML
@@ -364,5 +521,4 @@ function removeInlineStylesFromAll(product) {
product.description_short = removeStyles(product.description_short) product.description_short = removeStyles(product.description_short)
product.usage = removeStyles(product.usage) product.usage = removeStyles(product.usage)
} }
</script> </script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex flex-1"> <div class="flex flex-1 overflow-x-hidden h-svh">
<USidebar v-model:open="open" collapsible="icon" rail :ui="{ <USidebar v-model:open="open" collapsible="icon" rail :ui="{
container: 'h-full', container: 'h-full z-80',
inner: 'bg-elevated/25 divide-transparent', inner: 'bg-elevated/25 divide-transparent',
body: 'py-0' body: 'py-0'
}"> }">
@@ -32,10 +32,10 @@
</USidebar> </USidebar>
<div class="flex-1 flex flex-col"> <div class="flex-1 flex flex-col">
<div class="h-(--ui-header-height) shrink-0 flex items-center justify-between px-4 border-b border-default"> <div class="flex h-(--ui-header-height) shrink-0 items-center justify-between px-4 border-b border-default">
<UButton icon="i-lucide-panel-left" color="neutral" variant="ghost" aria-label="Toggle sidebar" <UButton icon="i-lucide-panel-left" color="neutral" variant="ghost" aria-label="Toggle sidebar"
@click="open = !open" /> @click="open = !open" />
<div class="flex items-center gap-12"> <div class="hidden md:flex items-center gap-12">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<CountryCurrencySwitch /> <CountryCurrencySwitch />
<LangSwitch /> <LangSwitch />
@@ -50,7 +50,7 @@
</div> </div>
</div> </div>
<div class="flex-1 p-4"> <div class="flex-1 p-4 bg-slate-50 h-svh">
<slot /> <slot />
</div> </div>
</div> </div>
@@ -158,6 +158,7 @@ import { useFetchJson } from '@/composable/useFetchJson'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import CountryCurrencySwitch from '@/components/inner/CountryCurrencySwitch.vue' import CountryCurrencySwitch from '@/components/inner/CountryCurrencySwitch.vue'
import LangSwitch from '@/components/inner/LangSwitch.vue' import LangSwitch from '@/components/inner/LangSwitch.vue'
import ThemeSwitch from '@/components/inner/ThemeSwitch.vue'
const router = useRouter() const router = useRouter()

View File

@@ -54,7 +54,6 @@ export const useProductStore = defineStore('product', () => {
try { try {
const response = await useFetchJson<ProductDescription>(`/api/v1/restricted/product-translation/translate-product-description?productID=${productID}&productFromLangID=${settingStore.shopDefaultLanguage}&productToLangID=${toLangId}&model=${model}`) const response = await useFetchJson<ProductDescription>(`/api/v1/restricted/product-translation/translate-product-description?productID=${productID}&productFromLangID=${settingStore.shopDefaultLanguage}&productToLangID=${toLangId}&model=${model}`)
productDescription.value = response.items productDescription.value = response.items
saveProductDescription(productID, toLangId)
return response.items return response.items
} catch (e: any) { } catch (e: any) {
error.value = e?.message || 'Failed to translate product description' error.value = e?.message || 'Failed to translate product description'
@@ -64,42 +63,6 @@ export const useProductStore = defineStore('product', () => {
} }
} }
function fixHtml(html: string) {
return html
// 1. fix img
.replace(/<img([^>]*?)>/g, '<img$1 />')
// 2. escape text only
.replace(/>([^<]+)</g, (match, text) => {
const escaped = text
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
return `>${escaped}<`
})
}
function fixAll(obj: any): any {
if (typeof obj === 'string') {
return fixHtml(obj)
}
if (Array.isArray(obj)) {
return obj.map(fixAll)
}
if (typeof obj === 'object' && obj !== null) {
const result: any = {}
for (const [key, value] of Object.entries(obj)) {
result[key] = fixAll(value)
}
return result
}
return obj
}
async function saveProductDescription(productID?: number, langId?: number | null) { async function saveProductDescription(productID?: number, langId?: number | null) {
const id = productID || 1 const id = productID || 1
const lang = langId || 1 const lang = langId || 1