admin_szolgáltatók_
This commit is contained in:
145
frontend_admin/components/LanguageSwitcher.vue
Normal file
145
frontend_admin/components/LanguageSwitcher.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="relative" ref="dropdownRef">
|
||||
<!-- Trigger Button -->
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg transition bg-slate-700/50 hover:bg-slate-700 text-slate-300 hover:text-white border border-slate-600/50"
|
||||
@click="dropdownOpen = !dropdownOpen"
|
||||
>
|
||||
<span class="text-base leading-none">{{ flagFor(currentLocale) }}</span>
|
||||
<span class="hidden sm:inline">{{ labelFor(currentLocale) }}</span>
|
||||
<svg
|
||||
class="w-3.5 h-3.5 text-slate-400 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': dropdownOpen }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown Menu -->
|
||||
<div
|
||||
v-if="dropdownOpen"
|
||||
class="absolute right-0 mt-2 w-44 bg-slate-800 border border-slate-700 rounded-xl shadow-xl py-1 z-50"
|
||||
>
|
||||
<button
|
||||
v-for="loc in availableLocales"
|
||||
:key="loc.code"
|
||||
class="flex items-center gap-3 w-full px-4 py-2.5 text-sm transition"
|
||||
:class="
|
||||
loc.code === currentLocale
|
||||
? 'bg-indigo-600/20 text-indigo-300'
|
||||
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||
"
|
||||
@click="selectLocale(loc.code)"
|
||||
>
|
||||
<span class="text-lg leading-none">{{ flagFor(loc.code) }}</span>
|
||||
<span>{{ loc.name }}</span>
|
||||
<span v-if="loc.code === currentLocale" class="ml-auto text-indigo-400">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { locale, locales } = useI18n()
|
||||
|
||||
const dropdownOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// ── Locale helpers ──────────────────────────────────────────────
|
||||
|
||||
interface LocaleEntry {
|
||||
code: string
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const availableLocales = computed(() => locales.value as LocaleEntry[])
|
||||
const currentLocale = computed(() => locale.value)
|
||||
|
||||
/** Map of language codes to flag emojis */
|
||||
const flagMap: Record<string, string> = {
|
||||
hu: '🇭🇺',
|
||||
en: '🇬🇧',
|
||||
}
|
||||
|
||||
/** Map of language codes to display labels (short) */
|
||||
const labelMap: Record<string, string> = {
|
||||
hu: 'Magyar',
|
||||
en: 'English',
|
||||
}
|
||||
|
||||
function flagFor(code: string): string {
|
||||
return flagMap[code] || '🌐'
|
||||
}
|
||||
|
||||
function labelFor(code: string): string {
|
||||
return labelMap[code] || code.toUpperCase()
|
||||
}
|
||||
|
||||
// ── Locale switching ────────────────────────────────────────────
|
||||
|
||||
function getAuthHeaders() {
|
||||
const tokenCookie = useCookie('access_token')
|
||||
const token = tokenCookie.value
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
async function selectLocale(code: string) {
|
||||
if (code === currentLocale.value) {
|
||||
dropdownOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Update vue-i18n locale immediately
|
||||
locale.value = code
|
||||
|
||||
// 2. Persist to localStorage
|
||||
try {
|
||||
localStorage.setItem('preferred_language', code)
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
|
||||
// 3. Persist to backend
|
||||
try {
|
||||
await $fetch('/api/v1/auth/me/language', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { preferred_language: code },
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[LanguageSwitcher] Failed to persist language preference:', err)
|
||||
}
|
||||
|
||||
dropdownOpen.value = false
|
||||
}
|
||||
|
||||
// ── Close on outside click ──────────────────────────────────────
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) {
|
||||
dropdownOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
67
frontend_admin/components/TreeNode.vue
Normal file
67
frontend_admin/components/TreeNode.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 py-1">
|
||||
<button
|
||||
@click="$emit('toggle-expand', node.id)"
|
||||
class="text-slate-500 hover:text-slate-300 transition w-4 h-4 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 transition-transform"
|
||||
:class="expandedIds.has(node.id) ? 'rotate-90' : ''"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<label class="flex items-center gap-2 cursor-pointer flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.includes(node.id)"
|
||||
@change="$emit('toggle', node.id)"
|
||||
class="w-4 h-4 rounded border-slate-500 bg-slate-700 text-indigo-500 focus:ring-indigo-500/50"
|
||||
/>
|
||||
<span class="text-sm text-slate-300">{{ node.name_hu || node.name_en || node.key }}</span>
|
||||
<span class="text-xs text-slate-500">(Szint {{ node.level }})</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="expandedIds.has(node.id) && node.children?.length" class="ml-6 border-l border-slate-700 pl-3">
|
||||
<div v-for="child in node.children" :key="child.id">
|
||||
<TreeNode
|
||||
:node="child"
|
||||
:selected-ids="selectedIds"
|
||||
:expanded-ids="expandedIds"
|
||||
@toggle="(id: number) => $emit('toggle', id)"
|
||||
@toggle-expand="(id: number) => $emit('toggle-expand', id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Recursive TreeNode component for the category tree.
|
||||
* Renders a checkbox tree with expand/collapse for nested categories.
|
||||
*/
|
||||
interface CategoryTreeNode {
|
||||
id: number
|
||||
key: string
|
||||
name_hu?: string | null
|
||||
name_en?: string | null
|
||||
level: number
|
||||
path?: string | null
|
||||
is_official: boolean
|
||||
children: CategoryTreeNode[]
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
node: CategoryTreeNode
|
||||
selectedIds: number[]
|
||||
expandedIds: Set<number>
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
toggle: [id: number]
|
||||
'toggle-expand': [id: number]
|
||||
}>()
|
||||
</script>
|
||||
Reference in New Issue
Block a user