feat(i18n): add PuroLocaleSwitcher for portal pages
This commit is contained in:
150
frontend/src/components/puro/PuroLocaleSwitcher.vue
Normal file
150
frontend/src/components/puro/PuroLocaleSwitcher.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<div class="puro-locale" ref="dropdownRef">
|
||||||
|
<button
|
||||||
|
class="puro-locale-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="switching"
|
||||||
|
:aria-expanded="isOpen"
|
||||||
|
:title="currentLocale?.name"
|
||||||
|
@click="toggleDropdown"
|
||||||
|
>
|
||||||
|
<span class="puro-locale-code">{{ currentLocale?.code.toUpperCase() }}</span>
|
||||||
|
<svg class="puro-locale-chev" :class="{ open: isOpen }" viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
|
||||||
|
<path d="M2 4l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<transition name="puro-locale-pop">
|
||||||
|
<div v-if="isOpen" class="puro-locale-menu" role="listbox">
|
||||||
|
<button
|
||||||
|
v-for="option in availableLocales"
|
||||||
|
:key="option.code"
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="option.code === currentLocaleCode"
|
||||||
|
class="puro-locale-option"
|
||||||
|
:class="{ active: option.code === currentLocaleCode }"
|
||||||
|
:disabled="switching"
|
||||||
|
@click="selectLocale(option.code)"
|
||||||
|
>
|
||||||
|
<span class="puro-locale-option-code">{{ option.code.toUpperCase() }}</span>
|
||||||
|
<span class="puro-locale-option-name">{{ option.name }}</span>
|
||||||
|
<span v-if="option.code === currentLocaleCode" class="puro-locale-option-dot" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { setLocale, availableLocales } from '@/i18n'
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const dropdownRef = ref<HTMLElement | null>(null)
|
||||||
|
const switching = ref(false)
|
||||||
|
|
||||||
|
const currentLocaleCode = computed(() => locale.value)
|
||||||
|
const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value))
|
||||||
|
|
||||||
|
function toggleDropdown() {
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectLocale(code: string) {
|
||||||
|
if (switching.value || code === currentLocaleCode.value) {
|
||||||
|
isOpen.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switching.value = true
|
||||||
|
try {
|
||||||
|
await setLocale(code)
|
||||||
|
isOpen.value = false
|
||||||
|
} finally {
|
||||||
|
switching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('click', handleClickOutside))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.puro-locale { position: relative; display: inline-flex; }
|
||||||
|
|
||||||
|
.puro-locale-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||||
|
font-size: 12px; font-weight: 500;
|
||||||
|
color: var(--text-1, #cbd5e1);
|
||||||
|
background: rgba(2, 6, 23, 0.4);
|
||||||
|
border: 1px solid var(--border, rgba(148,163,184,0.18));
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s, color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.puro-locale-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--border-2, rgba(148,163,184,0.32));
|
||||||
|
color: var(--cyan, #22d3ee);
|
||||||
|
}
|
||||||
|
.puro-locale-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.puro-locale-code { letter-spacing: 0.08em; }
|
||||||
|
.puro-locale-chev { color: var(--text-3, #64748b); transition: transform .15s; }
|
||||||
|
.puro-locale-chev.open { transform: rotate(180deg); color: var(--cyan, #22d3ee); }
|
||||||
|
|
||||||
|
.puro-locale-menu {
|
||||||
|
position: absolute; top: calc(100% + 6px); right: 0;
|
||||||
|
min-width: 140px;
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
border: 1px solid var(--border, rgba(148,163,184,0.18));
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puro-locale-option {
|
||||||
|
width: 100%;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: transparent; border: none; border-radius: 6px;
|
||||||
|
font-size: 13px; color: var(--text-1, #cbd5e1);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background .12s, color .12s;
|
||||||
|
}
|
||||||
|
.puro-locale-option:hover:not(:disabled) {
|
||||||
|
background: rgba(34, 211, 238, 0.08);
|
||||||
|
color: var(--text-0, #f8fafc);
|
||||||
|
}
|
||||||
|
.puro-locale-option.active {
|
||||||
|
color: var(--cyan, #22d3ee);
|
||||||
|
}
|
||||||
|
.puro-locale-option-code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px; letter-spacing: 0.08em;
|
||||||
|
color: var(--text-3, #64748b);
|
||||||
|
}
|
||||||
|
.puro-locale-option.active .puro-locale-option-code { color: var(--cyan, #22d3ee); }
|
||||||
|
.puro-locale-option-name { flex: 1; }
|
||||||
|
.puro-locale-option-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--cyan, #22d3ee);
|
||||||
|
box-shadow: 0 0 0 3px rgba(34,211,238,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puro-locale-pop-enter-active,
|
||||||
|
.puro-locale-pop-leave-active { transition: opacity .12s, transform .12s; }
|
||||||
|
.puro-locale-pop-enter-from,
|
||||||
|
.puro-locale-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.97); }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user