From 08d2a1f3cf90c5cd75efb18e2dd0900ef7339721 Mon Sep 17 00:00:00 2001
From: byteful
Date: Fri, 10 Jul 2026 06:40:06 -0500
Subject: [PATCH 1/4] audit/analytics improvements
---
.../src/components/settings/UsageSettings.tsx | 11 ++++-
client/src/locales/de.json | 1 +
client/src/locales/en.json | 1 +
client/src/locales/es.json | 1 +
client/src/locales/fr.json | 1 +
client/src/locales/hi.json | 1 +
client/src/locales/it.json | 1 +
client/src/locales/ja.json | 1 +
client/src/locales/nl.json | 1 +
client/src/locales/pt.json | 1 +
client/src/locales/ru.json | 1 +
client/src/locales/zh.json | 1 +
client/src/pages/audit.tsx | 41 ++++++++-----------
client/src/utils/date-utils.ts | 12 ++++++
14 files changed, 50 insertions(+), 25 deletions(-)
diff --git a/client/src/components/settings/UsageSettings.tsx b/client/src/components/settings/UsageSettings.tsx
index 2270d23..415802c 100644
--- a/client/src/components/settings/UsageSettings.tsx
+++ b/client/src/components/settings/UsageSettings.tsx
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
-import { HardDrive, Search, Trash2, Download, FolderOpen, Calendar, AlertCircle, Settings, CreditCard, Brain, Play } from 'lucide-react';
+import { HardDrive, Search, Trash2, Download, FolderOpen, Calendar, AlertCircle, Settings, CreditCard, Brain, Play, Info } from 'lucide-react';
import { getApiUrl, getCurrentDomain, apiFetch } from '@/lib/api';
import { Button } from '@modl-gg/shared-web/components/ui/button';
import { Input } from '@modl-gg/shared-web/components/ui/input';
@@ -889,6 +889,15 @@ const fetchStorageData = async () => {
+ {replayRetentionEnabled && (
+
+
+
+ {t('settings.usage.replayRetentionExemptionNote')}
+
+
+ )}
+
{replayRetentionEnabled
diff --git a/client/src/locales/de.json b/client/src/locales/de.json
index e46c5c1..382b828 100644
--- a/client/src/locales/de.json
+++ b/client/src/locales/de.json
@@ -796,6 +796,7 @@
"replayRetentionEnabledDesc": "Gespeicherte Replays nach der konfigurierten Aufbewahrungszeit automatisch löschen.",
"replayRetentionDays": "Aufbewahrungszeitraum (Tage)",
"replayRetentionDaysDesc": "Gespeicherte Replays, die älter als diese Anzahl von Tagen sind, können bereinigt werden.",
+ "replayRetentionExemptionNote": "An offene Tickets oder Berufungen angehängte Replays werden nie gelöscht, auch nicht nach Ablauf der Aufbewahrungszeit.",
"replayRetentionDisabledDesc": "Automatische Replay-Bereinigung ist deaktiviert. Gespeicherte Replays bleiben erhalten, bis sie manuell gelöscht werden.",
"replayRetentionActive": "Replays werden nach {{days}} Tagen gelöscht",
"replayRetentionOff": "Automatische Bereinigung deaktiviert",
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 4d7e93c..c860f04 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -796,6 +796,7 @@
"replayRetentionEnabledDesc": "Automatically delete stored replays after the configured retention period.",
"replayRetentionDays": "Retention period (days)",
"replayRetentionDaysDesc": "Stored replays older than this many days are eligible for cleanup.",
+ "replayRetentionExemptionNote": "Replays attached to open tickets or appeals are never deleted, even after the retention period.",
"replayRetentionDisabledDesc": "Automatic replay cleanup is disabled. Stored replays will be retained until manually deleted.",
"replayRetentionActive": "Deleting replays after {{days}} days",
"replayRetentionOff": "Automatic cleanup disabled",
diff --git a/client/src/locales/es.json b/client/src/locales/es.json
index b342d39..b011505 100644
--- a/client/src/locales/es.json
+++ b/client/src/locales/es.json
@@ -796,6 +796,7 @@
"replayRetentionEnabledDesc": "Eliminar automáticamente las repeticiones almacenadas después del periodo de retención configurado.",
"replayRetentionDays": "Periodo de retención (días)",
"replayRetentionDaysDesc": "Las repeticiones almacenadas con más de esta cantidad de días podrán limpiarse.",
+ "replayRetentionExemptionNote": "Las repeticiones adjuntas a tickets o apelaciones abiertos nunca se eliminan, incluso después del periodo de retención.",
"replayRetentionDisabledDesc": "La limpieza automática de repeticiones está deshabilitada. Las repeticiones almacenadas se conservarán hasta que se eliminen manualmente.",
"replayRetentionActive": "Eliminando repeticiones después de {{days}} días",
"replayRetentionOff": "Limpieza automática deshabilitada",
diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json
index 348edf8..2a51a92 100644
--- a/client/src/locales/fr.json
+++ b/client/src/locales/fr.json
@@ -796,6 +796,7 @@
"replayRetentionEnabledDesc": "Supprimer automatiquement les replays stockés après la période de rétention configurée.",
"replayRetentionDays": "Période de rétention (jours)",
"replayRetentionDaysDesc": "Les replays stockés plus anciens que ce nombre de jours peuvent être nettoyés.",
+ "replayRetentionExemptionNote": "Les replays associés à des tickets ou appels ouverts ne sont jamais supprimés, même après la période de rétention.",
"replayRetentionDisabledDesc": "Le nettoyage automatique des replays est désactivé. Les replays stockés seront conservés jusqu'à leur suppression manuelle.",
"replayRetentionActive": "Suppression des replays après {{days}} jours",
"replayRetentionOff": "Nettoyage automatique désactivé",
diff --git a/client/src/locales/hi.json b/client/src/locales/hi.json
index fd1a269..68429bf 100644
--- a/client/src/locales/hi.json
+++ b/client/src/locales/hi.json
@@ -796,6 +796,7 @@
"replayRetentionEnabledDesc": "कॉन्फ़िगर की गई प्रतिधारण अवधि के बाद संग्रहीत रिप्ले को स्वचालित रूप से हटाएं।",
"replayRetentionDays": "प्रतिधारण अवधि (दिन)",
"replayRetentionDaysDesc": "इस संख्या से अधिक पुराने संग्रहीत रिप्ले सफाई के पात्र होंगे।",
+ "replayRetentionExemptionNote": "खुले टिकट या अपील से जुड़े रिप्ले कभी नहीं हटाए जाते, भले ही प्रतिधारण अवधि समाप्त हो गई हो।",
"replayRetentionDisabledDesc": "स्वचालित रिप्ले सफाई अक्षम है। संग्रहीत रिप्ले तब तक रहेंगे जब तक उन्हें मैन्युअल रूप से हटाया नहीं जाता।",
"replayRetentionActive": "{{days}} दिनों के बाद रिप्ले हटाया जा रहा है",
"replayRetentionOff": "स्वचालित सफाई अक्षम",
diff --git a/client/src/locales/it.json b/client/src/locales/it.json
index 7587905..2559839 100644
--- a/client/src/locales/it.json
+++ b/client/src/locales/it.json
@@ -796,6 +796,7 @@
"replayRetentionEnabledDesc": "Elimina automaticamente i replay archiviati dopo il periodo di conservazione configurato.",
"replayRetentionDays": "Periodo di conservazione (giorni)",
"replayRetentionDaysDesc": "I replay archiviati più vecchi di questo numero di giorni sono soggetti a pulizia.",
+ "replayRetentionExemptionNote": "I replay allegati a ticket o appelli aperti non vengono mai eliminati, anche dopo il periodo di conservazione.",
"replayRetentionDisabledDesc": "La pulizia automatica dei replay è disabilitata. I replay archiviati verranno conservati fino all'eliminazione manuale.",
"replayRetentionActive": "Eliminazione dei replay dopo {{days}} giorni",
"replayRetentionOff": "Pulizia automatica disabilitata",
diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json
index ddfa385..5ceabb3 100644
--- a/client/src/locales/ja.json
+++ b/client/src/locales/ja.json
@@ -796,6 +796,7 @@
"replayRetentionEnabledDesc": "設定された保持期間を過ぎた保存済みリプレイを自動的に削除します。",
"replayRetentionDays": "保持期間 (日)",
"replayRetentionDaysDesc": "この日数より古い保存済みリプレイは削除の対象となります。",
+ "replayRetentionExemptionNote": "未解決のチケットや不服申し立てに添付されたリプレイは、保持期間を過ぎても削除されません。",
"replayRetentionDisabledDesc": "自動リプレイ削除は無効になっています。保存されたリプレイは手動で削除されるまで保持されます。",
"replayRetentionActive": "{{days}} 日後にリプレイを削除中",
"replayRetentionOff": "自動削除は無効です",
diff --git a/client/src/locales/nl.json b/client/src/locales/nl.json
index b387745..c148f56 100644
--- a/client/src/locales/nl.json
+++ b/client/src/locales/nl.json
@@ -796,6 +796,7 @@
"replayRetentionEnabledDesc": "Verwijder opgeslagen replays automatisch na de ingestelde bewaartermijn.",
"replayRetentionDays": "Bewaartermijn (dagen)",
"replayRetentionDaysDesc": "Opgeslagen replays ouder dan dit aantal dagen komen in aanmerking voor opruiming.",
+ "replayRetentionExemptionNote": "Replays die aan open tickets of beroepen zijn gekoppeld, worden nooit verwijderd, zelfs niet na de bewaartermijn.",
"replayRetentionDisabledDesc": "Automatische replay-opruiming is uitgeschakeld. Opgeslagen replays blijven bewaard totdat ze handmatig worden verwijderd.",
"replayRetentionActive": "Replays worden na {{days}} dagen verwijderd",
"replayRetentionOff": "Automatische opruiming uitgeschakeld",
diff --git a/client/src/locales/pt.json b/client/src/locales/pt.json
index 0c2bb7a..fb43fe4 100644
--- a/client/src/locales/pt.json
+++ b/client/src/locales/pt.json
@@ -796,6 +796,7 @@
"replayRetentionEnabledDesc": "Excluir automaticamente os replays armazenados após o período de retenção configurado.",
"replayRetentionDays": "Período de retenção (dias)",
"replayRetentionDaysDesc": "Replays armazenados mais antigos que este número de dias serão qualificados para limpeza.",
+ "replayRetentionExemptionNote": "Os replays anexados a tickets ou apelações abertos nunca são excluídos, mesmo após o período de retenção.",
"replayRetentionDisabledDesc": "A limpeza automática de replays está desativada. Os replays armazenados serão retidos até serem excluídos manualmente.",
"replayRetentionActive": "Excluindo replays após {{days}} dias",
"replayRetentionOff": "Limpeza automática desativada",
diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json
index abf24bc..5cb822e 100644
--- a/client/src/locales/ru.json
+++ b/client/src/locales/ru.json
@@ -800,6 +800,7 @@
"replayRetentionEnabledDesc": "Автоматически удалять сохраненные записи по истечении настроенного срока хранения.",
"replayRetentionDays": "Срок хранения (дней)",
"replayRetentionDaysDesc": "Сохраненные записи старше указанного количества дней будут удалены при очистке.",
+ "replayRetentionExemptionNote": "Записи, прикреплённые к открытым тикетам или апелляциям, никогда не удаляются, даже по истечении срока хранения.",
"replayRetentionDisabledDesc": "Автоматическая очистка записей отключена. Сохраненные записи будут храниться до тех пор, пока не будут удалены вручную.",
"replayRetentionActive": "Удаление записей через {{days}} дн.",
"replayRetentionOff": "Автоматическая очистка отключена",
diff --git a/client/src/locales/zh.json b/client/src/locales/zh.json
index 436e424..b46f01c 100644
--- a/client/src/locales/zh.json
+++ b/client/src/locales/zh.json
@@ -796,6 +796,7 @@
"replayRetentionEnabledDesc": "在配置的保留期过后自动删除存储的回放。",
"replayRetentionDays": "保留期(天)",
"replayRetentionDaysDesc": "超过此天数的存储回放将符合清理条件。",
+ "replayRetentionExemptionNote": "附加到未解决工单或申诉的回放永远不会被删除,即使超过保留期。",
"replayRetentionDisabledDesc": "已禁用自动回放清理。存储的回放将被保留,直到手动删除。",
"replayRetentionActive": "将在 {{days}} 天后删除回放",
"replayRetentionOff": "自动清理已禁用",
diff --git a/client/src/pages/audit.tsx b/client/src/pages/audit.tsx
index db8b4f3..1840d4c 100644
--- a/client/src/pages/audit.tsx
+++ b/client/src/pages/audit.tsx
@@ -32,7 +32,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@modl-gg/shared-web/components/ui/alert-dialog';
import { Textarea } from '@modl-gg/shared-web/components/ui/textarea';
import { subDays } from 'date-fns';
-import { formatDateOnly } from '@/utils/date-utils';
+import { formatChartDateLabel, formatDateOnly } from '@/utils/date-utils';
import { useLogs } from '@/hooks/use-data';
import { useQuery } from '@tanstack/react-query';
import PageContainer from '@/components/layout/PageContainer';
@@ -294,14 +294,15 @@ interface CustomTooltipProps {
label?: string | number;
formatValue?: (value: number | string, name?: string) => string;
formatName?: (name: string) => string;
+ formatLabel?: (label: string | number) => string;
}
// Custom themed tooltip component for charts
-const CustomTooltip = ({ active, payload, label, formatValue, formatName }: CustomTooltipProps) => {
+const CustomTooltip = ({ active, payload, label, formatValue, formatName, formatLabel }: CustomTooltipProps) => {
if (active && payload && payload.length) {
return (
- {label &&
{label}
}
+ {label &&
{formatLabel ? formatLabel(label) : label}
}
{payload.map((entry, index) => (
-
+
- } />
+ } />
@@ -1275,9 +1276,9 @@ const TicketAnalyticsSection = ({ analyticsPeriod }: { analyticsPeriod: string }
-
+
- } />
+ } />
{
});
}
- // Convert to array and sort by date
- return Array.from(dateMap.values()).sort((a, b) => {
- // Handle different date formats (e.g., "Jan 15" vs "2024-01-15")
- const dateA = new Date(a.date);
- const dateB = new Date(b.date);
- if (!isNaN(dateA.getTime()) && !isNaN(dateB.getTime())) {
- return dateA.getTime() - dateB.getTime();
- }
- return a.date.localeCompare(b.date);
- });
+ return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date));
}, [ticketUpdatedAt, punishmentUpdatedAt, playerActivityUpdatedAt]);
const maxLoginsByCountry = useMemo(() => {
@@ -1827,7 +1819,7 @@ const AuditLog = () => {
}, [playerActivityUpdatedAt]);
// Memoize a single CustomTooltip element used across many charts.
- const customTooltipEl = useMemo(() => , []);
+ const customTooltipEl = useMemo(() => , []);
return (
@@ -1918,10 +1910,11 @@ const AuditLog = () => {
-
+
{
switch (name) {
case 'tickets': return t('audit.legendNewTickets');
@@ -2032,9 +2025,9 @@ const AuditLog = () => {
-
+
- name === "count" ? "Count" : name} />} />
+ name === "count" ? "Count" : name} />} />
@@ -2188,9 +2181,9 @@ const AuditLog = () => {
-
+
- name === "count" ? "New Players" : name} />} />
+ name === "count" ? "New Players" : name} />} />
@@ -2199,7 +2192,7 @@ const AuditLog = () => {
-
+
diff --git a/client/src/utils/date-utils.ts b/client/src/utils/date-utils.ts
index 3b889a3..5829837 100644
--- a/client/src/utils/date-utils.ts
+++ b/client/src/utils/date-utils.ts
@@ -36,6 +36,18 @@ const formatDateParts = (date: Date, includeTime: boolean): string => {
return `${datePart} ${hh}:${min}`;
};
+const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
+
+export const formatChartDateLabel = (label: string | number): string => {
+ const text = String(label);
+ const match = ISO_DATE_PATTERN.exec(text);
+ if (!match) {
+ return text;
+ }
+ const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
+ return formatDateParts(date, false);
+};
+
export const formatDate = (dateString: string): string => {
try {
if (!dateString || dateString === 'Invalid Date') {
From d231e6699ed1a1aba178986de4e7291c118ab39f Mon Sep 17 00:00:00 2001
From: byteful
Date: Sat, 11 Jul 2026 02:51:43 -0500
Subject: [PATCH 2/4] fixes.
---
.../components/settings/AccountSettings.tsx | 2 +
client/src/hooks/use-auth.tsx | 6 +--
client/src/pages/settings.tsx | 10 ++--
client/src/utils/date-utils.ts | 53 +++++++++++++++++--
4 files changed, 60 insertions(+), 11 deletions(-)
diff --git a/client/src/components/settings/AccountSettings.tsx b/client/src/components/settings/AccountSettings.tsx
index 606ae26..691d5e9 100644
--- a/client/src/components/settings/AccountSettings.tsx
+++ b/client/src/components/settings/AccountSettings.tsx
@@ -20,6 +20,7 @@ import { useAuth } from '@/hooks/use-auth';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '@/lib/api';
import { SUPPORTED_LANGUAGES } from '@/lib/languages';
+import { PRETTY_DATE_FORMAT, formatPrettyDateTime } from '@/utils/date-utils';
interface AccountSettingsProps {
profileUsername: string;
@@ -418,6 +419,7 @@ const AccountSettings = ({
+ {formatPrettyDateTime(new Date(2026, 0, 15, 12, 0))}
MM/DD/YYYY HH:mm
DD/MM/YYYY HH:mm
YYYY-MM-DD HH:mm
diff --git a/client/src/hooks/use-auth.tsx b/client/src/hooks/use-auth.tsx
index f239938..681d011 100644
--- a/client/src/hooks/use-auth.tsx
+++ b/client/src/hooks/use-auth.tsx
@@ -2,7 +2,7 @@ import { createContext, type ReactNode, useCallback, useContext, useEffect, useM
import { useLocation } from "wouter";
import { useToast } from "@modl-gg/shared-web/hooks/use-toast";
import { getApiUrl, getCurrentDomain } from "@/lib/api";
-import { setDateLocale, setDateFormat } from "@/utils/date-utils";
+import { DEFAULT_DATE_FORMAT, setDateLocale, setDateFormat } from "@/utils/date-utils";
import { startAuthentication, type PublicKeyCredentialRequestOptionsJSON } from "@simplewebauthn/browser";
import { isWebAuthnCancellation, unwrapPublicKeyOptions, type MaybePublicKeyWrapped } from "@/utils/webauthn";
import i18n from "@/lib/i18n";
@@ -70,7 +70,7 @@ function mapUserFromMeResponse(userData: MeResponse): User {
role: userData.role,
minecraftUsername: userData.minecraftUsername,
language: userData.language || undefined,
- dateFormat: userData.dateFormat || 'MM/DD/YYYY',
+ dateFormat: userData.dateFormat || DEFAULT_DATE_FORMAT,
};
}
@@ -114,7 +114,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const lang = user?.language || 'en';
- const dateFormat = user?.dateFormat || 'MM/DD/YYYY';
+ const dateFormat = user?.dateFormat || DEFAULT_DATE_FORMAT;
setDateLocale(lang);
setDateFormat(dateFormat);
}, [user?.language, user?.dateFormat]);
diff --git a/client/src/pages/settings.tsx b/client/src/pages/settings.tsx
index b34d7aa..a68c73a 100644
--- a/client/src/pages/settings.tsx
+++ b/client/src/pages/settings.tsx
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next';
import { Scale, Shield, Globe, Tag, Plus, X, Trash2, MessageCircle, Save, CheckCircle, User as UserIcon, CreditCard, BookOpen, Settings as SettingsIcon, ChevronDown, ChevronRight, Layers, GripVertical, Edit3, Users, Bot, FileText, Home, Bell, Crown, Database, type LucideIcon } from 'lucide-react';
import { getApiUrl, getCurrentDomain, apiFetch, apiUpload } from '@/lib/api';
-import { setDateLocale, setDateFormat as setDateFormatUtil } from '@/utils/date-utils';
+import { DEFAULT_DATE_FORMAT, setDateLocale, setDateFormat as setDateFormatUtil } from '@/utils/date-utils';
import i18n from '@/lib/i18n';
import { Button } from '@modl-gg/shared-web/components/ui/button';
import { Card, CardContent } from '@modl-gg/shared-web/components/ui/card';
@@ -659,7 +659,7 @@ const Settings = () => {
// Refs to capture latest profile values for auto-save
const profileUsernameRef = useRef('');
const languageRef = useRef('en');
- const dateFormatRef = useRef('MM/DD/YYYY');
+ const dateFormatRef = useRef(DEFAULT_DATE_FORMAT);
const usernameDirtyRef = useRef(false);
const languageDirtyRef = useRef(false);
const dateFormatDirtyRef = useRef(false);
@@ -803,7 +803,7 @@ const Settings = () => {
const [apiKeyCopied, setApiKeyCopied] = useState(false); // Profile settings state
const [profileUsernameState, setProfileUsernameState] = useState('');
const [languageState, setLanguageState] = useState('en');
- const [dateFormatState, setDateFormatState] = useState('MM/DD/YYYY');
+ const [dateFormatState, setDateFormatState] = useState(DEFAULT_DATE_FORMAT);
// AI Moderation settings state
const [aiModerationSettings, setAiModerationSettings] = useState({
@@ -932,11 +932,11 @@ const Settings = () => {
if (user) {
justLoadedFromServerRef.current = true; // Prevent auto-save during initial load
setProfileUsernameState(user.username || '');
- setDateFormatState(user.dateFormat || 'MM/DD/YYYY');
+ setDateFormatState(user.dateFormat || DEFAULT_DATE_FORMAT);
// Initialize the refs with the current values
profileUsernameRef.current = user.username || '';
- dateFormatRef.current = user.dateFormat || 'MM/DD/YYYY';
+ dateFormatRef.current = user.dateFormat || DEFAULT_DATE_FORMAT;
usernameDirtyRef.current = false;
languageDirtyRef.current = false;
diff --git a/client/src/utils/date-utils.ts b/client/src/utils/date-utils.ts
index 5829837..0498339 100644
--- a/client/src/utils/date-utils.ts
+++ b/client/src/utils/date-utils.ts
@@ -1,4 +1,8 @@
-let _dateFormat = 'MM/DD/YYYY';
+export const PRETTY_DATE_FORMAT = 'MMM D, YYYY';
+export const DEFAULT_DATE_FORMAT = PRETTY_DATE_FORMAT;
+
+let _dateFormat = DEFAULT_DATE_FORMAT;
+let _dateLocale = 'en';
export const setDateFormat = (fmt: string) => {
_dateFormat = fmt;
@@ -6,12 +10,55 @@ export const setDateFormat = (fmt: string) => {
export const getDateFormat = () => _dateFormat;
-// Keep setDateLocale as a no-op for backwards compat (called from use-auth)
-export const setDateLocale = (_lang: string) => {};
+export const setDateLocale = (lang: string) => {
+ _dateLocale = lang || 'en';
+};
+
+type PrettyVariant = 'date' | 'time';
+
+const prettyFormatterCache = new Map();
+
+const prettyFormatterOptions = (variant: PrettyVariant): Intl.DateTimeFormatOptions => {
+ if (variant === 'date') {
+ return { month: 'short', day: 'numeric', year: 'numeric' };
+ }
+ const timeOptions: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' };
+ return _dateLocale.startsWith('en') ? { ...timeOptions, hour12: true } : timeOptions;
+};
+
+const getPrettyFormatter = (variant: PrettyVariant): Intl.DateTimeFormat => {
+ const key = `${_dateLocale}:${variant}`;
+ const cached = prettyFormatterCache.get(key);
+ if (cached) {
+ return cached;
+ }
+ let formatter: Intl.DateTimeFormat;
+ try {
+ formatter = new Intl.DateTimeFormat(_dateLocale, prettyFormatterOptions(variant));
+ } catch {
+ formatter = new Intl.DateTimeFormat('en', prettyFormatterOptions(variant));
+ }
+ prettyFormatterCache.set(key, formatter);
+ return formatter;
+};
+
+export const formatPrettyDate = (date: Date): string => getPrettyFormatter('date').format(date);
+
+export const formatPrettyDateTime = (date: Date): string => {
+ const time = getPrettyFormatter('time')
+ .formatToParts(date)
+ .map((part) => (part.type === 'dayPeriod' ? part.value.toLowerCase() : part.value))
+ .join('');
+ return `${formatPrettyDate(date)} - ${time}`;
+};
const pad = (n: number): string => n.toString().padStart(2, '0');
const formatDateParts = (date: Date, includeTime: boolean): string => {
+ if (_dateFormat === PRETTY_DATE_FORMAT) {
+ return includeTime ? formatPrettyDateTime(date) : formatPrettyDate(date);
+ }
+
const mm = pad(date.getMonth() + 1);
const dd = pad(date.getDate());
const yyyy = date.getFullYear().toString();
From 1b67ec4ee9edab737eb8040a1a4eec3d9be0ca42 Mon Sep 17 00:00:00 2001
From: byteful
Date: Sun, 26 Jul 2026 00:02:50 -0500
Subject: [PATCH 3/4] domain fixes
---
.../components/settings/DomainSettings.tsx | 49 +++++++++++--------
client/src/locales/de.json | 3 +-
client/src/locales/en.json | 3 +-
client/src/locales/es.json | 3 +-
client/src/locales/fr.json | 3 +-
client/src/locales/hi.json | 3 +-
client/src/locales/it.json | 3 +-
client/src/locales/ja.json | 3 +-
client/src/locales/nl.json | 3 +-
client/src/locales/pt.json | 3 +-
client/src/locales/ru.json | 3 +-
client/src/locales/zh.json | 3 +-
12 files changed, 39 insertions(+), 43 deletions(-)
diff --git a/client/src/components/settings/DomainSettings.tsx b/client/src/components/settings/DomainSettings.tsx
index 5047cb4..463b734 100644
--- a/client/src/components/settings/DomainSettings.tsx
+++ b/client/src/components/settings/DomainSettings.tsx
@@ -56,24 +56,40 @@ const DomainSettings: React.FC = () => {
const loadDomainConfig = async () => {
try {
const response = await apiFetch('/v1/panel/settings/domain');
- if (response.ok) {
- const data = await response.json();
- if (data.customDomain) {
- setCustomDomain(data.customDomain);
- setDomainStatus(data.status);
- }
- setAccessingFromCustomDomain(data.accessingFromCustomDomain || false);
- setModlSubdomainUrl(data.modlSubdomainUrl || '');
- setCanManageCustomDomain(Boolean(data.canManageCustomDomain));
+ if (!response.ok) {
+ toast({
+ title: t('toast.error'),
+ description: t('settings.domain.loadFailed'),
+ variant: "destructive",
+ });
+ return;
+ }
+ const data = await response.json();
+ if (data.customDomain) {
+ setCustomDomain(data.customDomain);
+ setDomainStatus(data.status);
}
+ setAccessingFromCustomDomain(data.accessingFromCustomDomain || false);
+ setModlSubdomainUrl(data.modlSubdomainUrl || '');
+ setCanManageCustomDomain(Boolean(data.canManageCustomDomain));
} catch (error) {
- // Domain configuration may not exist yet
+ toast({
+ title: t('toast.error'),
+ description: t('settings.domain.loadFailed'),
+ variant: "destructive",
+ });
}
};
const validateDomain = (domain: string): boolean => {
- const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9](?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9])*$/;
- return domainRegex.test(domain) && domain.length <= 253;
+ const normalized = domain.trim();
+ if (normalized.length === 0 || normalized.length > 253) {
+ return false;
+ }
+ if (!normalized.includes('.')) {
+ return false;
+ }
+ return !/\s/.test(normalized);
};
const handleDomainSubmit = async () => {
@@ -104,15 +120,6 @@ const DomainSettings: React.FC = () => {
return;
}
- if (domainStatus?.domain && domainStatus.domain.toLowerCase() === customDomain.trim().toLowerCase()) {
- toast({
- title: t('settings.domain.alreadyConfigured'),
- description: t('settings.domain.alreadyConfiguredDesc'),
- variant: "destructive",
- });
- return;
- }
-
setIsLoading(true);
try {
const response = await apiFetch('/v1/panel/settings/domain', {
diff --git a/client/src/locales/de.json b/client/src/locales/de.json
index 382b828..6c4388a 100644
--- a/client/src/locales/de.json
+++ b/client/src/locales/de.json
@@ -243,8 +243,7 @@
"enterDomain": "Bitte gib einen Domainnamen ein.",
"invalidDomain": "Ungültige Domain",
"invalidDomainDesc": "Bitte gib einen gültigen Domainnamen ein (z. B. panel.beispiel.de).",
- "alreadyConfigured": "Bereits konfiguriert",
- "alreadyConfiguredDesc": "Diese Domain ist bereits konfiguriert.",
+ "loadFailed": "Domain-Einstellungen konnten nicht geladen werden.",
"configurationError": "Konfigurationsfehler",
"cloudflareConfigFailed": "Domain konnte nicht mit Cloudflare konfiguriert werden.",
"configurationStarted": "Konfiguration gestartet",
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index c860f04..f2d4557 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -243,8 +243,7 @@
"enterDomain": "Please enter a domain name.",
"invalidDomain": "Invalid Domain",
"invalidDomainDesc": "Please enter a valid domain name (e.g. panel.example.com).",
- "alreadyConfigured": "Already Configured",
- "alreadyConfiguredDesc": "This domain is already configured.",
+ "loadFailed": "Failed to load domain settings.",
"configurationError": "Configuration Error",
"cloudflareConfigFailed": "Failed to configure domain with Cloudflare.",
"configurationStarted": "Configuration Started",
diff --git a/client/src/locales/es.json b/client/src/locales/es.json
index b011505..ec49c56 100644
--- a/client/src/locales/es.json
+++ b/client/src/locales/es.json
@@ -243,8 +243,7 @@
"enterDomain": "Por favor introduce un nombre de dominio.",
"invalidDomain": "Dominio inválido",
"invalidDomainDesc": "Por favor introduce un nombre de dominio válido (ej. panel.ejemplo.com).",
- "alreadyConfigured": "Ya configurado",
- "alreadyConfiguredDesc": "Este dominio ya está configurado.",
+ "loadFailed": "No se pudo cargar la configuración del dominio.",
"configurationError": "Error de configuración",
"cloudflareConfigFailed": "No se pudo configurar el dominio con Cloudflare.",
"configurationStarted": "Configuración iniciada",
diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json
index 2a51a92..2e4bdf9 100644
--- a/client/src/locales/fr.json
+++ b/client/src/locales/fr.json
@@ -243,8 +243,7 @@
"enterDomain": "Veuillez entrer un nom de domaine.",
"invalidDomain": "Domaine invalide",
"invalidDomainDesc": "Veuillez entrer un nom de domaine valide (ex: panel.exemple.com).",
- "alreadyConfigured": "Déjà configuré",
- "alreadyConfiguredDesc": "Ce domaine est déjà configuré.",
+ "loadFailed": "Échec du chargement des paramètres du domaine.",
"configurationError": "Erreur de configuration",
"cloudflareConfigFailed": "Échec de la configuration du domaine avec Cloudflare.",
"configurationStarted": "Configuration démarrée",
diff --git a/client/src/locales/hi.json b/client/src/locales/hi.json
index 68429bf..4c55840 100644
--- a/client/src/locales/hi.json
+++ b/client/src/locales/hi.json
@@ -243,8 +243,7 @@
"enterDomain": "कृपया एक डोमेन नाम दर्ज करें।",
"invalidDomain": "अमान्य डोमेन",
"invalidDomainDesc": "कृपया एक मान्य डोमेन नाम दर्ज करें (जैसे panel.example.com)।",
- "alreadyConfigured": "पहले से कॉन्फ़िगर है",
- "alreadyConfiguredDesc": "यह डोमेन पहले से ही कॉन्फ़िगर है।",
+ "loadFailed": "डोमेन सेटिंग्स लोड करने में विफल।",
"configurationError": "कॉन्फ़िगरेशन त्रुटि",
"cloudflareConfigFailed": "Cloudflare के साथ डोमेन कॉन्फ़िगर करने में विफल।",
"configurationStarted": "कॉन्फ़िगरेशन प्रारंभ",
diff --git a/client/src/locales/it.json b/client/src/locales/it.json
index 2559839..ca0b10b 100644
--- a/client/src/locales/it.json
+++ b/client/src/locales/it.json
@@ -243,8 +243,7 @@
"enterDomain": "Inserisci un nome di dominio.",
"invalidDomain": "Dominio non valido",
"invalidDomainDesc": "Inserisci un nome di dominio valido (es. panel.example.com).",
- "alreadyConfigured": "Già configurato",
- "alreadyConfiguredDesc": "Questo dominio è già configurato.",
+ "loadFailed": "Impossibile caricare le impostazioni del dominio.",
"configurationError": "Errore di configurazione",
"cloudflareConfigFailed": "Impossibile configurare il dominio con Cloudflare.",
"configurationStarted": "Configurazione avviata",
diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json
index 5ceabb3..ebc0cc4 100644
--- a/client/src/locales/ja.json
+++ b/client/src/locales/ja.json
@@ -243,8 +243,7 @@
"enterDomain": "ドメイン名を入力してください。",
"invalidDomain": "無効なドメイン",
"invalidDomainDesc": "有効なドメイン名を入力してください(例:panel.example.com)。",
- "alreadyConfigured": "構成済み",
- "alreadyConfiguredDesc": "このドメインは既に構成されています。",
+ "loadFailed": "ドメイン設定の読み込みに失敗しました。",
"configurationError": "構成エラー",
"cloudflareConfigFailed": "Cloudflareでのドメインの構成に失敗しました。",
"configurationStarted": "構成の開始",
diff --git a/client/src/locales/nl.json b/client/src/locales/nl.json
index c148f56..536618f 100644
--- a/client/src/locales/nl.json
+++ b/client/src/locales/nl.json
@@ -243,8 +243,7 @@
"enterDomain": "Voer een domeinnaam in.",
"invalidDomain": "Ongeldig domein",
"invalidDomainDesc": "Voer een geldige domeinnaam in (bijv. panel.voorbeeld.nl).",
- "alreadyConfigured": "Al geconfigureerd",
- "alreadyConfiguredDesc": "Dit domein is al geconfigureerd.",
+ "loadFailed": "Domeininstellingen laden mislukt.",
"configurationError": "Configuratiefout",
"cloudflareConfigFailed": "Domein configureren met Cloudflare mislukt.",
"configurationStarted": "Configuratie gestart",
diff --git a/client/src/locales/pt.json b/client/src/locales/pt.json
index fb43fe4..e949a78 100644
--- a/client/src/locales/pt.json
+++ b/client/src/locales/pt.json
@@ -243,8 +243,7 @@
"enterDomain": "Por favor, insira um nome de domínio.",
"invalidDomain": "Domínio Inválido",
"invalidDomainDesc": "Por favor, insira um nome de domínio válido (ex: painel.exemplo.com).",
- "alreadyConfigured": "Já Configurado",
- "alreadyConfiguredDesc": "Este domínio já está configurado.",
+ "loadFailed": "Falha ao carregar as configurações do domínio.",
"configurationError": "Erro de Configuração",
"cloudflareConfigFailed": "Falha ao configurar o domínio com a Cloudflare.",
"configurationStarted": "Configuração Iniciada",
diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json
index 5cb822e..d1182b5 100644
--- a/client/src/locales/ru.json
+++ b/client/src/locales/ru.json
@@ -245,8 +245,7 @@
"enterDomain": "Пожалуйста, введите доменное имя.",
"invalidDomain": "Неверный домен",
"invalidDomainDesc": "Пожалуйста, введите корректное доменное имя (например, panel.example.com).",
- "alreadyConfigured": "Уже настроен",
- "alreadyConfiguredDesc": "Этот домен уже настроен.",
+ "loadFailed": "Не удалось загрузить настройки домена.",
"configurationError": "Ошибка конфигурации",
"cloudflareConfigFailed": "Не удалось настроить домен в Cloudflare.",
"configurationStarted": "Настройка запущена",
diff --git a/client/src/locales/zh.json b/client/src/locales/zh.json
index b46f01c..20c5a5c 100644
--- a/client/src/locales/zh.json
+++ b/client/src/locales/zh.json
@@ -243,8 +243,7 @@
"enterDomain": "请输入域名。",
"invalidDomain": "域名无效",
"invalidDomainDesc": "请输入有效的域名(例如 panel.example.com)。",
- "alreadyConfigured": "已配置",
- "alreadyConfiguredDesc": "该域名已被配置。",
+ "loadFailed": "加载域名设置失败。",
"configurationError": "配置错误",
"cloudflareConfigFailed": "在 Cloudflare 中配置域名失败。",
"configurationStarted": "已启动配置",
From f3627d648bde921ffcf1092b1892e159e97c1d33 Mon Sep 17 00:00:00 2001
From: byteful
Date: Sun, 26 Jul 2026 01:39:26 -0500
Subject: [PATCH 4/4] 2.3.1
---
package-lock.json | 4 ++--
package.json | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index bb036c2..6c26bff 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "modl-panel",
- "version": "2.3.0",
+ "version": "2.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modl-panel",
- "version": "2.3.0",
+ "version": "2.3.1",
"license": "AGPL-3.0-only",
"dependencies": {
"@hookform/resolvers": "^3.10.0",
diff --git a/package.json b/package.json
index f3fdb69..8f3579d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "modl-panel",
- "version": "2.3.0",
+ "version": "2.3.1",
"type": "module",
"license": "AGPL-3.0-only",
"scripts": {