Benjamin Harris 1 месяц назад
Родитель
Сommit
e41a5cfd97
6 измененных файлов с 211 добавлено и 21 удалено
  1. 5 0
      ui/src/locales/en.ts
  2. 5 0
      ui/src/locales/tr.ts
  3. 6 2
      ui/src/stores/compose.ts
  4. 127 0
      ui/src/utils/timezone.ts
  5. 52 18
      ui/src/views/Compose.vue
  6. 16 1
      ui/src/views/Settings.vue

+ 5 - 0
ui/src/locales/en.ts

@@ -47,6 +47,8 @@ export default {
     sending: 'Posting...',
     successMessage: 'Post sent successfully.',
     scheduleTitle: 'Schedule post (leave empty to post now)',
+    timezoneLabel: 'Timezone',
+    timezoneAutoFrom: 'Auto from account',
     preview: 'Preview',
     addMedia: 'Photo / Video',
     uploadFile: 'Upload a photo or video from your device',
@@ -152,6 +154,9 @@ export default {
       hashtagsHint: 'e.g. #specialtycoffee #coffeelovers',
       postingGuidelines: 'Posting Guidelines',
       postingGuidelinesHint: 'Any specific rules, e.g. always mention opening hours on Fridays',
+      timezone: 'Timezone',
+      timezoneHint: 'Used to schedule posts at the correct local time for this account.',
+      timezoneAuto: 'Use browser timezone',
     },
 
     meta: {

+ 5 - 0
ui/src/locales/tr.ts

@@ -47,6 +47,8 @@ export default {
     sending: 'Gönderiliyor...',
     successMessage: 'Gönderi başarıyla gönderildi.',
     scheduleTitle: 'Zamanlama (boş bırakırsan hemen gönderilir)',
+    timezoneLabel: 'Saat Dilimi',
+    timezoneAutoFrom: 'Hesaptan otomatik',
     preview: 'Önizleme',
     addMedia: 'Fotoğraf / Video',
     uploadFile: 'Cihazından bir fotoğraf veya video yükle',
@@ -152,6 +154,9 @@ export default {
       hashtagsHint: 'örn. #kahveseverler #özelkahve',
       postingGuidelines: 'Yayın Kuralları',
       postingGuidelinesHint: 'Özel kurallar, örn. Cuma günleri açılış saatlerini belirt',
+      timezone: 'Saat Dilimi',
+      timezoneHint: 'Bu hesap için gönderilerin doğru yerel saatte zamanlanması için kullanılır.',
+      timezoneAuto: 'Tarayıcı saat dilimini kullan',
     },
 
     meta: {

+ 6 - 2
ui/src/stores/compose.ts

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
 import { ref, computed } from 'vue'
 import axios from 'axios'
 import { usePlatformsStore, PLATFORM_META } from './platforms'
+import { naiveDatetimeToUtc } from '../utils/timezone'
 
 export interface Destination {
   key: string        // 'twitter', 'facebook:PAGE_ID', 'instagram:ACCOUNT_ID'
@@ -151,7 +152,7 @@ export const useComposeStore = defineStore('compose', () => {
     }
   }
 
-  async function post() {
+  async function post(timezone?: string) {
     const selected = selectedDestinations.value
     if (!content.value.trim() || !selected.length) return
     sending.value = true
@@ -165,9 +166,12 @@ export const useComposeStore = defineStore('compose', () => {
       }))
 
       if (scheduledAt.value) {
+        const utcScheduledAt = timezone
+          ? naiveDatetimeToUtc(scheduledAt.value, timezone)
+          : new Date(scheduledAt.value).toISOString()
         await axios.post('/scheduler/schedule', {
           content: content.value,
-          scheduledAt: scheduledAt.value,
+          scheduledAt: utcScheduledAt,
           destinations: destPayload,
         })
       } else {

+ 127 - 0
ui/src/utils/timezone.ts

@@ -0,0 +1,127 @@
+export interface TimezoneOption {
+  value: string
+  label: string
+}
+
+export const COMMON_TIMEZONES: TimezoneOption[] = [
+  { value: 'UTC', label: 'UTC' },
+  // Americas
+  { value: 'America/New_York',    label: 'New York (ET)' },
+  { value: 'America/Chicago',     label: 'Chicago (CT)' },
+  { value: 'America/Denver',      label: 'Denver (MT)' },
+  { value: 'America/Los_Angeles', label: 'Los Angeles (PT)' },
+  { value: 'America/Anchorage',   label: 'Anchorage (AKT)' },
+  { value: 'Pacific/Honolulu',    label: 'Honolulu (HT)' },
+  { value: 'America/Toronto',     label: 'Toronto (ET)' },
+  { value: 'America/Vancouver',   label: 'Vancouver (PT)' },
+  { value: 'America/Mexico_City', label: 'Mexico City (CT)' },
+  { value: 'America/Sao_Paulo',   label: 'São Paulo (BRT)' },
+  { value: 'America/Argentina/Buenos_Aires', label: 'Buenos Aires (ART)' },
+  { value: 'America/Bogota',      label: 'Bogotá (COT)' },
+  { value: 'America/Lima',        label: 'Lima (PET)' },
+  { value: 'America/Santiago',    label: 'Santiago (CLT)' },
+  // Europe
+  { value: 'Europe/London',       label: 'London (GMT/BST)' },
+  { value: 'Europe/Dublin',       label: 'Dublin (GMT/IST)' },
+  { value: 'Europe/Lisbon',       label: 'Lisbon (WET)' },
+  { value: 'Europe/Paris',        label: 'Paris (CET)' },
+  { value: 'Europe/Berlin',       label: 'Berlin (CET)' },
+  { value: 'Europe/Rome',         label: 'Rome (CET)' },
+  { value: 'Europe/Madrid',       label: 'Madrid (CET)' },
+  { value: 'Europe/Amsterdam',    label: 'Amsterdam (CET)' },
+  { value: 'Europe/Stockholm',    label: 'Stockholm (CET)' },
+  { value: 'Europe/Warsaw',       label: 'Warsaw (CET)' },
+  { value: 'Europe/Helsinki',     label: 'Helsinki (EET)' },
+  { value: 'Europe/Athens',       label: 'Athens (EET)' },
+  { value: 'Europe/Bucharest',    label: 'Bucharest (EET)' },
+  { value: 'Europe/Istanbul',     label: 'Istanbul (TRT)' },
+  { value: 'Europe/Moscow',       label: 'Moscow (MSK)' },
+  { value: 'Europe/Kiev',         label: 'Kyiv (EET)' },
+  // Africa
+  { value: 'Africa/Cairo',        label: 'Cairo (EET)' },
+  { value: 'Africa/Lagos',        label: 'Lagos (WAT)' },
+  { value: 'Africa/Nairobi',      label: 'Nairobi (EAT)' },
+  { value: 'Africa/Johannesburg', label: 'Johannesburg (SAST)' },
+  // Middle East / Asia
+  { value: 'Asia/Dubai',          label: 'Dubai (GST)' },
+  { value: 'Asia/Riyadh',         label: 'Riyadh (AST)' },
+  { value: 'Asia/Tehran',         label: 'Tehran (IRST)' },
+  { value: 'Asia/Karachi',        label: 'Karachi (PKT)' },
+  { value: 'Asia/Kolkata',        label: 'Kolkata (IST)' },
+  { value: 'Asia/Dhaka',          label: 'Dhaka (BST)' },
+  { value: 'Asia/Bangkok',        label: 'Bangkok (ICT)' },
+  { value: 'Asia/Ho_Chi_Minh',    label: 'Ho Chi Minh (ICT)' },
+  { value: 'Asia/Jakarta',        label: 'Jakarta (WIB)' },
+  { value: 'Asia/Kuala_Lumpur',   label: 'Kuala Lumpur (MYT)' },
+  { value: 'Asia/Singapore',      label: 'Singapore (SGT)' },
+  { value: 'Asia/Manila',         label: 'Manila (PHT)' },
+  { value: 'Asia/Shanghai',       label: 'Shanghai (CST)' },
+  { value: 'Asia/Hong_Kong',      label: 'Hong Kong (HKT)' },
+  { value: 'Asia/Taipei',         label: 'Taipei (CST)' },
+  { value: 'Asia/Seoul',          label: 'Seoul (KST)' },
+  { value: 'Asia/Tokyo',          label: 'Tokyo (JST)' },
+  // Pacific / Australia
+  { value: 'Australia/Perth',     label: 'Perth (AWST)' },
+  { value: 'Australia/Darwin',    label: 'Darwin (ACST)' },
+  { value: 'Australia/Brisbane',  label: 'Brisbane (AEST)' },
+  { value: 'Australia/Sydney',    label: 'Sydney (AEST/AEDT)' },
+  { value: 'Australia/Melbourne', label: 'Melbourne (AEST/AEDT)' },
+  { value: 'Pacific/Auckland',    label: 'Auckland (NZST)' },
+  { value: 'Pacific/Fiji',        label: 'Fiji (FJT)' },
+]
+
+export function getBrowserTimezone(): string {
+  try {
+    return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
+  } catch {
+    return 'UTC'
+  }
+}
+
+// Convert a naive "YYYY-MM-DDTHH:MM" string interpreted in `timezone` to a UTC ISO string.
+// Uses a two-iteration offset adjustment that correctly handles DST transitions.
+export function naiveDatetimeToUtc(localStr: string, timezone: string): string {
+  if (!localStr) return localStr
+  if (!timezone || timezone === 'UTC') return new Date(localStr + ':00Z').toISOString()
+
+  const m = localStr.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/)
+  if (!m) return new Date(localStr).toISOString()
+
+  const [, yr, mo, dy, hr, mn] = m.map(Number)
+
+  // Start with the naive datetime treated as UTC, then adjust for the real offset.
+  let guess = new Date(Date.UTC(yr, mo - 1, dy, hr, mn, 0))
+
+  const fmt = new Intl.DateTimeFormat('en-GB', {
+    timeZone: timezone,
+    year: 'numeric', month: '2-digit', day: '2-digit',
+    hour: '2-digit', minute: '2-digit', second: '2-digit',
+    hour12: false,
+  })
+
+  for (let i = 0; i < 2; i++) {
+    const p: Record<string, number> = {}
+    for (const { type, value } of fmt.formatToParts(guess)) {
+      if (type !== 'literal') p[type] = Number(value)
+    }
+    // hour can be 24 for midnight in some environments
+    const shown = new Date(Date.UTC(p.year, p.month - 1, p.day, p.hour % 24, p.minute, p.second ?? 0))
+    const desired = new Date(Date.UTC(yr, mo - 1, dy, hr, mn, 0))
+    guess = new Date(guess.getTime() + (desired.getTime() - shown.getTime()))
+  }
+
+  return guess.toISOString()
+}
+
+// Return the short timezone abbreviation shown in the UI badge.
+export function getTimezoneAbbr(timezone: string): string {
+  try {
+    const parts = new Intl.DateTimeFormat('en', {
+      timeZone: timezone,
+      timeZoneName: 'short',
+    }).formatToParts(new Date())
+    return parts.find((p) => p.type === 'timeZoneName')?.value ?? timezone
+  } catch {
+    return timezone
+  }
+}

+ 52 - 18
ui/src/views/Compose.vue

@@ -307,8 +307,9 @@
         </div>
 
         <!-- Schedule + Post -->
-        <div class="bg-gray-900 border border-gray-800 rounded-xl p-4 flex items-center gap-3 flex-wrap">
-          <div class="flex items-center gap-2 flex-1 min-w-0">
+        <div class="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
+          <!-- Row 1: datetime + timezone -->
+          <div class="flex items-center gap-2">
             <svg class="w-4 h-4 text-gray-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
               <path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
             </svg>
@@ -318,27 +319,39 @@
               class="flex-1 bg-transparent text-sm text-gray-300 focus:outline-none min-w-0"
               :title="$t('compose.scheduleTitle')"
             />
+            <!-- Timezone selector -->
+            <select
+              v-model="scheduleTimezone"
+              :title="$t('compose.timezoneLabel')"
+              class="text-xs bg-gray-800 border border-gray-700 rounded-md px-2 py-1 text-gray-400 focus:outline-none focus:border-amber-500 flex-shrink-0 max-w-[130px]"
+            >
+              <option v-for="tz in COMMON_TIMEZONES" :key="tz.value" :value="tz.value">{{ tz.label }}</option>
+            </select>
+            <span class="text-xs text-gray-600 flex-shrink-0 hidden sm:block">{{ timezoneAbbr }}</span>
             <button
               v-if="composeStore.scheduledAt"
               @click="composeStore.scheduledAt = ''"
               class="text-gray-600 hover:text-gray-400 text-xs flex-shrink-0"
             >✕</button>
           </div>
-          <button
-            @click="handleSaveDraft"
-            :disabled="composeStore.savingDraft || !composeStore.content.trim()"
-            class="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 flex-shrink-0 bg-gray-700 hover:bg-gray-600 text-gray-200"
-          >
-            {{ composeStore.savingDraft ? $t('compose.savingDraft') : (composeStore.draftId ? $t('compose.updateDraft') : $t('compose.saveDraft')) }}
-          </button>
-          <button
-            @click="handlePost"
-            :disabled="composeStore.sending || !canPost"
-            class="px-5 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 flex-shrink-0"
-            :class="composeStore.scheduledAt ? 'bg-amber-600 hover:bg-amber-700' : 'bg-blue-600 hover:bg-blue-700'"
-          >
-            {{ composeStore.sending ? $t('compose.sending') : postButtonLabel }}
-          </button>
+          <!-- Row 2: actions -->
+          <div class="flex items-center justify-end gap-2">
+            <button
+              @click="handleSaveDraft"
+              :disabled="composeStore.savingDraft || !composeStore.content.trim()"
+              class="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 bg-gray-700 hover:bg-gray-600 text-gray-200"
+            >
+              {{ composeStore.savingDraft ? $t('compose.savingDraft') : (composeStore.draftId ? $t('compose.updateDraft') : $t('compose.saveDraft')) }}
+            </button>
+            <button
+              @click="handlePost"
+              :disabled="composeStore.sending || !canPost"
+              class="px-5 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40"
+              :class="composeStore.scheduledAt ? 'bg-amber-600 hover:bg-amber-700' : 'bg-blue-600 hover:bg-blue-700'"
+            >
+              {{ composeStore.sending ? $t('compose.sending') : postButtonLabel }}
+            </button>
+          </div>
         </div>
 
         <!-- Success message -->
@@ -382,6 +395,7 @@ import { useComposeStore } from '../stores/compose'
 import { usePlatformsStore } from '../stores/platforms'
 import { useAiStore } from '../stores/ai'
 import PostPreview from '../components/compose/PostPreview.vue'
+import { COMMON_TIMEZONES, getBrowserTimezone, getTimezoneAbbr } from '../utils/timezone'
 
 const { t } = useI18n()
 const composeStore = useComposeStore()
@@ -493,6 +507,26 @@ function removeMedia() {
   showUrlInput.value = false
 }
 
+// ─── Schedule Timezone ────────────────────────────────────────────────────────
+
+const scheduleTimezone = ref(getBrowserTimezone())
+const timezoneAbbr = computed(() => getTimezoneAbbr(scheduleTimezone.value))
+
+// Auto-populate timezone from the first selected destination's profile.
+watch(
+  () => composeStore.selectedDestinations[0]?.key,
+  async (key: string | undefined) => {
+    if (!key) return
+    try {
+      const cached = profileCache[key]
+      const profile = cached ?? (await axios.get(`/api/profiles/${encodeURIComponent(key)}`)).data
+      if (!cached) profileCache[key] = profile
+      if (profile?.timezone) scheduleTimezone.value = profile.timezone
+    } catch { /* leave current timezone */ }
+  },
+  { immediate: true }
+)
+
 function isImage(url: string) {
   return /\.(jpe?g|png|gif|webp)(\?.*)?$/i.test(url)
 }
@@ -748,7 +782,7 @@ async function handleSaveDraft() {
 }
 
 async function handlePost() {
-  await composeStore.post()
+  await composeStore.post(scheduleTimezone.value)
   if (composeStore.lastResult) setTimeout(() => router.push('/dashboard'), 1500)
 }
 </script>

+ 16 - 1
ui/src/views/Settings.vue

@@ -379,6 +379,19 @@
                 </div>
               </div>
 
+              <!-- Timezone -->
+              <div>
+                <label class="block text-xs text-gray-500 mb-1">{{ $t('settings.profiles.timezone') }}</label>
+                <select
+                  v-model="editingProfiles[account.key].timezone"
+                  class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:border-blue-500"
+                >
+                  <option value="">{{ $t('settings.profiles.timezoneAuto') }}</option>
+                  <option v-for="tz in COMMON_TIMEZONES" :key="tz.value" :value="tz.value">{{ tz.label }}</option>
+                </select>
+                <p class="text-xs text-gray-600 mt-1">{{ $t('settings.profiles.timezoneHint') }}</p>
+              </div>
+
               <!-- Target Audience -->
               <div>
                 <label class="block text-xs text-gray-500 mb-1">{{ $t('settings.profiles.targetAudience') }}</label>
@@ -552,6 +565,7 @@ import { useI18n } from 'vue-i18n'
 import axios from 'axios'
 import { usePlatformsStore, PLATFORM_META } from '../stores/platforms'
 import { useAiStore } from '../stores/ai'
+import { COMMON_TIMEZONES } from '../utils/timezone'
 
 const { t } = useI18n()
 
@@ -664,6 +678,7 @@ interface AccountProfile {
   keywords: string
   hashtags: string
   postingGuidelines: string
+  timezone: string
 }
 
 interface ProfileAccount {
@@ -675,7 +690,7 @@ interface ProfileAccount {
 }
 
 function emptyProfile(): AccountProfile {
-  return { businessName: '', description: '', websiteUrl: '', industry: '', targetAudience: '', toneOfVoice: '', keywords: '', hashtags: '', postingGuidelines: '' }
+  return { businessName: '', description: '', websiteUrl: '', industry: '', targetAudience: '', toneOfVoice: '', keywords: '', hashtags: '', postingGuidelines: '', timezone: '' }
 }
 
 const expandedProfileKey = ref<string | null>(null)