Skip to content
16 changes: 13 additions & 3 deletions frontend/components/Leitner/LeitnerSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
$t('smart_review.next_session') }}</p>
<h3 class="text-2xl font-black text-gray-900 dark:text-white">{{ localSettings.reviewHour }}:00</h3>
<p class="text-xs text-gray-500">{{ localSettings.reviewInterval === 1 ? $t('smart_review.daily') :
$t('smart_review.every_days_reminder', { days: localSettings.reviewInterval }) }}</p>
$t('smart_review.every_days_reminder', { days: localSettings.reviewInterval }) }} ({{
profileStore.userDetail?.timeZone || 'UTC' }})</p>
</div>
<div class="absolute -right-4 -bottom-4 opacity-10 pointer-events-none">
<Icon name="IconClock" class="!w-24 !h-24 text-success" />
Expand Down Expand Up @@ -129,8 +130,17 @@
<label class="font-bold text-gray-800 dark:text-gray-200 leading-none">{{
$t('smart_review.review_interval') }}</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{
$t('smart_review.session_frequency_desc') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ $t('smart_review.session_frequency_desc') }}
<br />
<span class="text-[10px] opacity-75">
{{ $t('smart_review.based_on_local_time') }}
<NuxtLink to="/settings/profile"
class="underline hover:text-primary transition-colors">
{{ $t('smart_review.setup_timezone') }}
</NuxtLink>
</span>
</p>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-gray-400 uppercase">{{ $t('smart_review.each') }}</span>
Expand Down
170 changes: 170 additions & 0 deletions frontend/components/common/TimezonePicker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<template>
<Modal :title="t('profile.select_timezone')" size="md" :disabled="disabled">
<template #trigger="{ toggleModal }">
<div @click="scrollToSelected(); toggleModal(true)"
:class="['w-full cursor-pointer rounded-xl border bg-white px-4 py-3 text-sm font-medium transition-all duration-300 focus:border-primary focus:ring-4 focus:ring-primary/10 dark:bg-gray-800 dark:text-gray-300', disabled ? 'opacity-60 cursor-not-allowed bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-700' : 'border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-md dark:hover:border-gray-600']">
<div class="flex items-center justify-between">
<span v-if="modelValue" class="truncate text-gray-900 dark:text-white">{{ modelValue }}</span>
<span v-else class="text-gray-400">{{ t('profile.select_timezone') }}</span>
<Icon name="iconify solar--alt-arrow-down-bold-duotone"
class="h-4 w-4 text-gray-400 group-hover:text-primary transition-colors" />
</div>
</div>
</template>

<template #default>
<div class="flex flex-col h-[60vh] md:h-[500px]">
<div class="p-4 border-b border-gray-100 dark:border-gray-700">
<Input v-model="searchQuery" :placeholder="t('search')" icon="iconify solar--magnifer-linear"
class="w-full" autofocus />
</div>

<div v-if="tempSelectedTimezone && !searchQuery"
class="sticky top-0 z-10 border-b border-gray-100 bg-white/80 px-4 py-3 backdrop-blur-xl shadow-[0_4px_20px_rgba(0,0,0,0.03)] dark:border-gray-700 dark:bg-gray-800/80">
<p class="mb-1.5 text-[10px] font-bold uppercase tracking-widest text-gray-500 dark:text-gray-400">
{{ t('profile.current_timezone') }}
</p>
<div class="flex items-center gap-2.5 text-base font-bold text-gray-900 dark:text-white">
<div
class="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 text-primary shadow-inner">
<Icon name="iconify solar--clock-circle-bold-duotone" class="h-4 w-4" />
</div>
{{ tempSelectedTimezone }}
</div>
</div>

<div class="flex-1 overflow-y-auto p-2 scrollbar-custom" ref="listRef">
<div v-if="filteredTimezones.length === 0"
class="flex flex-col items-center justify-center py-12 text-gray-500">
<Icon name="iconify solar--magnifer-linear" class="h-12 w-12 mb-2 opacity-20" />
<p>{{ t('no_results_found') }}</p>
</div>

<ul v-else class="space-y-1">
<li v-for="tz in filteredTimezones" :key="tz" :data-tz="tz">
<button type="button" @click="selectTimezone(tz)"
class="w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all duration-300 flex items-center justify-between group"
:class="[
tempSelectedTimezone === tz
? 'bg-gradient-to-r from-primary/10 to-transparent text-primary font-bold shadow-sm ring-1 ring-primary/20 translate-x-1'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:translate-x-1 hover:text-gray-900 dark:hover:text-white'
]">
{{ tz }}
<Icon v-if="tempSelectedTimezone === tz" name="iconify solar--check-circle-bold"
class="h-5 w-5 text-primary drop-shadow-sm" />
</button>
</li>
</ul>
</div>
</div>
</template>

<template #footer="{ toggleModal }">
<div class="flex justify-end gap-3 p-4 border-t border-gray-100 dark:border-gray-700">
<Button shadow outline @click="toggleModal(false)">
{{ t('cancel') }}
</Button>
<Button shadow color="primary" @click="confirmSelection(toggleModal)" :disabled="!tempSelectedTimezone">
{{ t('confirm') }}
</Button>
</div>
</template>
</Modal>
</template>

<script setup lang="ts">
import { ref, computed, nextTick } from 'vue';
import { Modal } from '@codebridger/lib-vue-components/complex.ts';
import { Input, Button, Icon } from '@codebridger/lib-vue-components/elements.ts';
import { useI18n } from 'vue-i18n';

const props = defineProps<{
modelValue?: string;
disabled?: boolean;
}>();

const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();

const { t } = useI18n();
const isOpen = ref(false);
const searchQuery = ref('');
const listRef = ref<HTMLElement | null>(null);
const tempSelectedTimezone = ref('');

const availableTimeZones = Intl.supportedValuesOf('timeZone');

const filteredTimezones = computed(() => {
if (!searchQuery.value) return availableTimeZones;
const query = searchQuery.value.toLowerCase();
return availableTimeZones.filter(tz => tz.toLowerCase().includes(query));
});

function selectTimezone(tz: string) {
tempSelectedTimezone.value = tz;
}

function confirmSelection(toggleModal: (state: boolean) => void) {
if (tempSelectedTimezone.value) {
emit('update:modelValue', tempSelectedTimezone.value);
toggleModal(false);
searchQuery.value = '';
}
}

function scrollToSelected() {
tempSelectedTimezone.value = props.modelValue || '';

nextTick(() => {
if (tempSelectedTimezone.value) {
// Poll for the element to exist
const poll = setInterval(() => {
const container = listRef.value;
if (container && container.children.length > 0) {
// Use explicit attribute selector with CSS.escape for safety
const escapedValue = CSS.escape(tempSelectedTimezone.value || '');
const selectedEl = container.querySelector(`[data-tz="${escapedValue}"]`);

if (selectedEl) {
// Use scrollIntoView to scroll the element itself
selectedEl.scrollIntoView({ block: 'center' });

clearInterval(poll);
}
}
}, 100);

// Safety timeout to clear interval
setTimeout(() => clearInterval(poll), 2000);
}
});
}
</script>

<style scoped>
.scrollbar-custom::-webkit-scrollbar {
width: 6px;
}

.scrollbar-custom::-webkit-scrollbar-track {
background: transparent;
}

.scrollbar-custom::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}

.dark .scrollbar-custom::-webkit-scrollbar-thumb {
background: #334155;
}

.scrollbar-custom::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}

.dark .scrollbar-custom::-webkit-scrollbar-thumb:hover {
background: #475569;
}
</style>
9 changes: 8 additions & 1 deletion frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,11 @@
"reset-password": "Reset Password",
"uploading": "Uploading...",
"profile-updated": "Profile updated successfully",
"profile-update-failed": "Failed to update profile"
"profile-update-failed": "Failed to update profile",
"timezone": "Timezone",
"select_timezone": "Select Timezone",
"timezone_desc": "Select your local timezone so reviews appear at the correct time.",
"current_timezone": "Current Timezone"
},
"billing": {
"billing": "Billing",
Expand All @@ -204,6 +208,7 @@
"ai-coaching": "AI Coaching",
"or": "or",
"cancel": "Cancel",
"confirm": "Confirm",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
Expand Down Expand Up @@ -260,6 +265,8 @@
"session_frequency_desc": "Session frequency and time",
"each": "Each",
"day_at": "day at",
"based_on_local_time": "Time is based on your local time",
"setup_timezone": "Check TimeZone",
"cards": "Cards",
"entrance": "Entrance",
"mature": "Mature",
Expand Down
Loading