mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Various fixes: 2FA autofocus, viewer-timezone times, duplicate place guard (#1159)
* fix(auth): autofocus the 2FA code input when the MFA step appears (#767) * fix(notifications): show notification and admin times in the viewer timezone (#1149) SQLite CURRENT_TIMESTAMP is UTC but the string has no Z, so the client parsed it as local time. Normalize in-app notification created_at to ISO-UTC, and stop forcing the admin user table to render in the server timezone. * fix(places): warn before adding a duplicate place (#1152) Manually adding a place did not check the existing pool, so the same POI could land in Unplanned twice. Flag a likely duplicate by Google Place ID, name or near-identical coordinates and require a confirming second click to add anyway.
This commit is contained in:
@@ -39,6 +39,31 @@ interface PlaceFormModalProps {
|
|||||||
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
||||||
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
||||||
* render over the form fields. */
|
* render over the form fields. */
|
||||||
|
|
||||||
|
// #1152: a manually-added place is treated as a likely duplicate of an existing
|
||||||
|
// trip place if it shares the Google Place ID, the (case-insensitive) name, or
|
||||||
|
// near-identical coordinates (~11 m). Mirrors the server-side import dedup.
|
||||||
|
const DUP_COORD_TOLERANCE = 0.0001
|
||||||
|
function findDuplicatePlace(
|
||||||
|
form: PlaceFormData,
|
||||||
|
places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null }[],
|
||||||
|
): { name?: string | null } | null {
|
||||||
|
const name = (form.name || '').trim().toLowerCase()
|
||||||
|
const gid = (form.google_place_id || '').trim()
|
||||||
|
const lat = form.lat ? parseFloat(form.lat) : null
|
||||||
|
const lng = form.lng ? parseFloat(form.lng) : null
|
||||||
|
for (const p of places || []) {
|
||||||
|
if (gid && p.google_place_id && p.google_place_id === gid) return p
|
||||||
|
if (name && p.name && p.name.trim().toLowerCase() === name) return p
|
||||||
|
if (
|
||||||
|
lat != null && lng != null && p.lat != null && p.lng != null &&
|
||||||
|
Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE &&
|
||||||
|
Math.abs(Number(p.lng) - lng) <= DUP_COORD_TOLERANCE
|
||||||
|
) return p
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function usePlaceFormModal(props: PlaceFormModalProps) {
|
function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||||
const {
|
const {
|
||||||
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
||||||
@@ -51,6 +76,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
|
||||||
const [pendingFiles, setPendingFiles] = useState([])
|
const [pendingFiles, setPendingFiles] = useState([])
|
||||||
const fileRef = useRef(null)
|
const fileRef = useRef(null)
|
||||||
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
||||||
@@ -94,6 +120,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
setForm(DEFAULT_FORM)
|
setForm(DEFAULT_FORM)
|
||||||
}
|
}
|
||||||
setPendingFiles([])
|
setPendingFiles([])
|
||||||
|
setDuplicateWarning(null)
|
||||||
}, [place, prefillCoords, isOpen])
|
}, [place, prefillCoords, isOpen])
|
||||||
|
|
||||||
// Derive location bias bounding box from the trip's existing places
|
// Derive location bias bounding box from the trip's existing places
|
||||||
@@ -309,6 +336,17 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
toast.error(t('places.nameRequired'))
|
toast.error(t('places.nameRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// #1152: only for new places, and only on the first attempt — a second click
|
||||||
|
// (with the warning already showing) is the explicit "add anyway" confirmation.
|
||||||
|
if (!place && !duplicateWarning) {
|
||||||
|
const dup = findDuplicatePlace(form, places)
|
||||||
|
if (dup) {
|
||||||
|
const dupName = dup.name || form.name
|
||||||
|
setDuplicateWarning(dupName)
|
||||||
|
toast.warning(t('places.duplicateExists', { name: dupName }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
await onSave({
|
await onSave({
|
||||||
@@ -381,6 +419,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
handlePaste,
|
handlePaste,
|
||||||
hasTimeError,
|
hasTimeError,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
duplicateWarning,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +480,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
handlePaste,
|
handlePaste,
|
||||||
hasTimeError,
|
hasTimeError,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
duplicateWarning,
|
||||||
} = S
|
} = S
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -463,7 +503,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
disabled={isSaving || hasTimeError}
|
disabled={isSaving || hasTimeError}
|
||||||
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
||||||
>
|
>
|
||||||
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
|
{isSaving ? t('common.saving') : place ? t('common.update') : duplicateWarning ? t('places.addAnyway') : t('common.add')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -491,6 +491,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
|
||||||
placeholder="000000 or XXXX-XXXX"
|
placeholder="000000 or XXXX-XXXX"
|
||||||
required
|
required
|
||||||
|
autoFocus
|
||||||
style={inputBase}
|
style={inputBase}
|
||||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface AdminUsersTabProps {
|
|||||||
// create-invite modal. Pure layout around the useAdmin hook — no logic of its own.
|
// create-invite modal. Pure layout around the useAdmin hook — no logic of its own.
|
||||||
export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement {
|
export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement {
|
||||||
const {
|
const {
|
||||||
serverTimezone, hour12, currentUser,
|
hour12, currentUser,
|
||||||
users, isLoading,
|
users, isLoading,
|
||||||
setShowCreateUser,
|
setShowCreateUser,
|
||||||
invites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
invites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
||||||
@@ -92,10 +92,10 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-sm text-slate-500">
|
<td className="px-5 py-3 text-sm text-slate-500">
|
||||||
{new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
|
{new Date(u.created_at).toLocaleDateString(locale)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-sm text-slate-500">
|
<td className="px-5 py-3 text-sm text-slate-500">
|
||||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
|
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
@@ -162,7 +162,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-400 mt-0.5">
|
<div className="text-xs text-slate-400 mt-0.5">
|
||||||
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
|
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
|
||||||
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
|
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`}
|
||||||
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
|
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { broadcastToUser } from '../websocket';
|
|||||||
import { getAction } from './inAppNotificationActions';
|
import { getAction } from './inAppNotificationActions';
|
||||||
import { isEnabledForEvent, type NotifEventType } from './notificationPreferencesService';
|
import { isEnabledForEvent, type NotifEventType } from './notificationPreferencesService';
|
||||||
|
|
||||||
|
// SQLite's CURRENT_TIMESTAMP is UTC but the string ('YYYY-MM-DD HH:MM:SS') has
|
||||||
|
// no 'T'/'Z', so `new Date(...)` parses it as LOCAL time. Normalize to ISO-UTC
|
||||||
|
// so the client renders notification times in the viewer's own timezone (#1149).
|
||||||
|
function toUtcIso(ts: string): string {
|
||||||
|
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||||
|
}
|
||||||
|
|
||||||
type NotificationType = 'simple' | 'boolean' | 'navigate';
|
type NotificationType = 'simple' | 'boolean' | 'navigate';
|
||||||
type NotificationScope = 'trip' | 'user' | 'admin';
|
type NotificationScope = 'trip' | 'user' | 'admin';
|
||||||
type NotificationResponse = 'positive' | 'negative';
|
type NotificationResponse = 'positive' | 'negative';
|
||||||
@@ -218,6 +225,7 @@ export function createNotificationForRecipient(
|
|||||||
type: 'notification:new',
|
type: 'notification:new',
|
||||||
notification: {
|
notification: {
|
||||||
...row,
|
...row,
|
||||||
|
created_at: toUtcIso(row.created_at),
|
||||||
sender_username: sender?.username ?? null,
|
sender_username: sender?.username ?? null,
|
||||||
sender_avatar: sender?.avatar ? `/uploads/avatars/${sender.avatar}` : null,
|
sender_avatar: sender?.avatar ? `/uploads/avatars/${sender.avatar}` : null,
|
||||||
},
|
},
|
||||||
@@ -251,6 +259,7 @@ function getNotifications(
|
|||||||
|
|
||||||
const mapped = rows.map(r => ({
|
const mapped = rows.map(r => ({
|
||||||
...r,
|
...r,
|
||||||
|
created_at: toUtcIso(r.created_at),
|
||||||
sender_avatar: r.sender_avatar ? `/uploads/avatars/${r.sender_avatar}` : null,
|
sender_avatar: r.sender_avatar ? `/uploads/avatars/${r.sender_avatar}` : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -86,5 +86,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'فشل إنشاء الفئة',
|
'places.categoryCreateError': 'فشل إنشاء الفئة',
|
||||||
'places.nameRequired': 'يرجى إدخال اسم',
|
'places.nameRequired': 'يرجى إدخال اسم',
|
||||||
'places.saveError': 'فشل الحفظ',
|
'places.saveError': 'فشل الحفظ',
|
||||||
|
'places.duplicateExists': "'{name}' موجود بالفعل في هذه الرحلة.",
|
||||||
|
'places.addAnyway': 'الإضافة على أي حال',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -88,5 +88,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'Falha ao criar categoria',
|
'places.categoryCreateError': 'Falha ao criar categoria',
|
||||||
'places.nameRequired': 'Digite um nome',
|
'places.nameRequired': 'Digite um nome',
|
||||||
'places.saveError': 'Falha ao salvar',
|
'places.saveError': 'Falha ao salvar',
|
||||||
|
'places.duplicateExists': "'{name}' já está nesta viagem.",
|
||||||
|
'places.addAnyway': 'Adicionar mesmo assim',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -87,5 +87,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'Nepodařilo se vytvořit kategorii',
|
'places.categoryCreateError': 'Nepodařilo se vytvořit kategorii',
|
||||||
'places.nameRequired': 'Prosím zadejte název',
|
'places.nameRequired': 'Prosím zadejte název',
|
||||||
'places.saveError': 'Uložení se nezdařilo',
|
'places.saveError': 'Uložení se nezdařilo',
|
||||||
|
'places.duplicateExists': "'{name}' už v tomto výletu existuje.",
|
||||||
|
'places.addAnyway': 'Přesto přidat',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -88,5 +88,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
|
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
|
||||||
'places.nameRequired': 'Bitte einen Namen eingeben',
|
'places.nameRequired': 'Bitte einen Namen eingeben',
|
||||||
'places.saveError': 'Fehler beim Speichern',
|
'places.saveError': 'Fehler beim Speichern',
|
||||||
|
'places.duplicateExists': "'{name}' ist bereits in dieser Reise.",
|
||||||
|
'places.addAnyway': 'Trotzdem hinzufügen',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -87,5 +87,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'Failed to create category',
|
'places.categoryCreateError': 'Failed to create category',
|
||||||
'places.nameRequired': 'Please enter a name',
|
'places.nameRequired': 'Please enter a name',
|
||||||
'places.saveError': 'Failed to save',
|
'places.saveError': 'Failed to save',
|
||||||
|
'places.duplicateExists': "'{name}' is already in this trip.",
|
||||||
|
'places.addAnyway': 'Add anyway',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -88,5 +88,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'No se pudo crear la categoría',
|
'places.categoryCreateError': 'No se pudo crear la categoría',
|
||||||
'places.nameRequired': 'Introduce un nombre',
|
'places.nameRequired': 'Introduce un nombre',
|
||||||
'places.saveError': 'No se pudo guardar',
|
'places.saveError': 'No se pudo guardar',
|
||||||
|
'places.duplicateExists': "'{name}' ya está en este viaje.",
|
||||||
|
'places.addAnyway': 'Añadir de todos modos',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -89,5 +89,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'Impossible de créer la catégorie',
|
'places.categoryCreateError': 'Impossible de créer la catégorie',
|
||||||
'places.nameRequired': 'Veuillez saisir un nom',
|
'places.nameRequired': 'Veuillez saisir un nom',
|
||||||
'places.saveError': "Échec de l'enregistrement",
|
'places.saveError': "Échec de l'enregistrement",
|
||||||
|
'places.duplicateExists': "'{name}' est déjà dans ce voyage.",
|
||||||
|
'places.addAnyway': 'Ajouter quand même',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -90,5 +90,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'Αποτυχία δημιουργίας κατηγορίας',
|
'places.categoryCreateError': 'Αποτυχία δημιουργίας κατηγορίας',
|
||||||
'places.nameRequired': 'Παρακαλώ εισαγάγετε ένα όνομα',
|
'places.nameRequired': 'Παρακαλώ εισαγάγετε ένα όνομα',
|
||||||
'places.saveError': 'Αποτυχία αποθήκευσης',
|
'places.saveError': 'Αποτυχία αποθήκευσης',
|
||||||
|
'places.duplicateExists': "Το '{name}' υπάρχει ήδη σε αυτό το ταξίδι.",
|
||||||
|
'places.addAnyway': 'Προσθήκη ούτως ή άλλως',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -89,5 +89,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'Nem sikerült létrehozni a kategóriát',
|
'places.categoryCreateError': 'Nem sikerült létrehozni a kategóriát',
|
||||||
'places.nameRequired': 'Kérjük, adj meg egy nevet',
|
'places.nameRequired': 'Kérjük, adj meg egy nevet',
|
||||||
'places.saveError': 'Nem sikerült menteni',
|
'places.saveError': 'Nem sikerült menteni',
|
||||||
|
'places.duplicateExists': "A(z) '{name}' már szerepel ebben az utazásban.",
|
||||||
|
'places.addAnyway': 'Hozzáadás mindenképp',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -88,5 +88,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'Gagal membuat kategori',
|
'places.categoryCreateError': 'Gagal membuat kategori',
|
||||||
'places.nameRequired': 'Harap masukkan nama',
|
'places.nameRequired': 'Harap masukkan nama',
|
||||||
'places.saveError': 'Gagal menyimpan',
|
'places.saveError': 'Gagal menyimpan',
|
||||||
|
'places.duplicateExists': "'{name}' sudah ada di perjalanan ini.",
|
||||||
|
'places.addAnyway': 'Tetap tambahkan',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -88,5 +88,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'Impossibile creare la categoria',
|
'places.categoryCreateError': 'Impossibile creare la categoria',
|
||||||
'places.nameRequired': 'Inserisci un nome',
|
'places.nameRequired': 'Inserisci un nome',
|
||||||
'places.saveError': 'Impossibile salvare',
|
'places.saveError': 'Impossibile salvare',
|
||||||
|
'places.duplicateExists': "'{name}' è già in questo viaggio.",
|
||||||
|
'places.addAnyway': 'Aggiungi comunque',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -89,5 +89,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'カテゴリの作成に失敗しました',
|
'places.categoryCreateError': 'カテゴリの作成に失敗しました',
|
||||||
'places.nameRequired': '名前を入力してください',
|
'places.nameRequired': '名前を入力してください',
|
||||||
'places.saveError': '保存に失敗しました',
|
'places.saveError': '保存に失敗しました',
|
||||||
|
'places.duplicateExists': '「{name}」はすでにこの旅程に含まれています。',
|
||||||
|
'places.addAnyway': 'それでも追加',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -86,5 +86,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': '카테고리 생성 실패',
|
'places.categoryCreateError': '카테고리 생성 실패',
|
||||||
'places.nameRequired': '이름을 입력하세요',
|
'places.nameRequired': '이름을 입력하세요',
|
||||||
'places.saveError': '저장 실패',
|
'places.saveError': '저장 실패',
|
||||||
|
'places.duplicateExists': "'{name}'은(는) 이미 이 여행에 있습니다.",
|
||||||
|
'places.addAnyway': '그래도 추가',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -89,5 +89,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'Categorie aanmaken mislukt',
|
'places.categoryCreateError': 'Categorie aanmaken mislukt',
|
||||||
'places.nameRequired': 'Voer een naam in',
|
'places.nameRequired': 'Voer een naam in',
|
||||||
'places.saveError': 'Opslaan mislukt',
|
'places.saveError': 'Opslaan mislukt',
|
||||||
|
'places.duplicateExists': "'{name}' staat al in deze reis.",
|
||||||
|
'places.addAnyway': 'Toch toevoegen',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'Nie udało się utworzyć kategorii',
|
'places.categoryCreateError': 'Nie udało się utworzyć kategorii',
|
||||||
'places.nameRequired': 'Proszę podać nazwę',
|
'places.nameRequired': 'Proszę podać nazwę',
|
||||||
'places.saveError': 'Nie udało się zapisać',
|
'places.saveError': 'Nie udało się zapisać',
|
||||||
|
'places.duplicateExists': "'{name}' jest już w tej podróży.",
|
||||||
|
'places.addAnyway': 'Dodaj mimo to',
|
||||||
'places.importNaverList': 'Lista Naver',
|
'places.importNaverList': 'Lista Naver',
|
||||||
'places.importList': 'Import listy',
|
'places.importList': 'Import listy',
|
||||||
'places.googleListHint': 'Wklej link do listy Google Maps.',
|
'places.googleListHint': 'Wklej link do listy Google Maps.',
|
||||||
|
|||||||
@@ -88,5 +88,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'Не удалось создать категорию',
|
'places.categoryCreateError': 'Не удалось создать категорию',
|
||||||
'places.nameRequired': 'Введите название',
|
'places.nameRequired': 'Введите название',
|
||||||
'places.saveError': 'Ошибка сохранения',
|
'places.saveError': 'Ошибка сохранения',
|
||||||
|
'places.duplicateExists': "'{name}' уже есть в этой поездке.",
|
||||||
|
'places.addAnyway': 'Всё равно добавить',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -87,5 +87,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'Kategori oluşturulamadı',
|
'places.categoryCreateError': 'Kategori oluşturulamadı',
|
||||||
'places.nameRequired': 'Lütfen bir ad girin',
|
'places.nameRequired': 'Lütfen bir ad girin',
|
||||||
'places.saveError': 'Kaydedilemedi',
|
'places.saveError': 'Kaydedilemedi',
|
||||||
|
'places.duplicateExists': "'{name}' zaten bu gezide var.",
|
||||||
|
'places.addAnyway': 'Yine de ekle',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -88,5 +88,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': 'Не вдалося створити категорію',
|
'places.categoryCreateError': 'Не вдалося створити категорію',
|
||||||
'places.nameRequired': 'Введіть назву',
|
'places.nameRequired': 'Введіть назву',
|
||||||
'places.saveError': 'Помилка збереження',
|
'places.saveError': 'Помилка збереження',
|
||||||
|
'places.duplicateExists': "'{name}' вже є в цій подорожі.",
|
||||||
|
'places.addAnyway': 'Все одно додати',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -83,5 +83,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': '建立分類失敗',
|
'places.categoryCreateError': '建立分類失敗',
|
||||||
'places.nameRequired': '請輸入名稱',
|
'places.nameRequired': '請輸入名稱',
|
||||||
'places.saveError': '儲存失敗',
|
'places.saveError': '儲存失敗',
|
||||||
|
'places.duplicateExists': "'{name}' 已在此行程中。",
|
||||||
|
'places.addAnyway': '仍要新增',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
@@ -83,5 +83,7 @@ const places: TranslationStrings = {
|
|||||||
'places.categoryCreateError': '创建分类失败',
|
'places.categoryCreateError': '创建分类失败',
|
||||||
'places.nameRequired': '请输入名称',
|
'places.nameRequired': '请输入名称',
|
||||||
'places.saveError': '保存失败',
|
'places.saveError': '保存失败',
|
||||||
|
'places.duplicateExists': "'{name}' 已在此行程中。",
|
||||||
|
'places.addAnyway': '仍然添加',
|
||||||
};
|
};
|
||||||
export default places;
|
export default places;
|
||||||
|
|||||||
Reference in New Issue
Block a user