feat: custom date time format (#718)
This commit is contained in:
parent
2a219ac042
commit
f89b8c3b52
11 changed files with 2626 additions and 57 deletions
|
|
@ -1,19 +1,74 @@
|
|||
import type * as React from "react";
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
const SelectSsrValueContext = React.createContext<{
|
||||
items: Map<string, string>;
|
||||
value: string | undefined;
|
||||
} | null>(null);
|
||||
|
||||
function getSelectItemText(children: React.ReactNode): string {
|
||||
return React.Children.toArray(children)
|
||||
.map((child) => {
|
||||
if (typeof child === "string" || typeof child === "number") {
|
||||
return String(child);
|
||||
}
|
||||
|
||||
if (!React.isValidElement<{ children?: React.ReactNode }>(child)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return getSelectItemText(child.props.children);
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function collectSelectItems(children: React.ReactNode, items = new Map<string, string>()) {
|
||||
for (const child of React.Children.toArray(children)) {
|
||||
if (!React.isValidElement<{ children?: React.ReactNode; value?: string }>(child)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((child.type === SelectItem || child.type === SelectPrimitive.Item) && typeof child.props.value === "string") {
|
||||
items.set(child.props.value, getSelectItemText(child.props.children));
|
||||
}
|
||||
|
||||
if (child.props.children) {
|
||||
collectSelectItems(child.props.children, items);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function Select({ children, value, ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
const items = collectSelectItems(children);
|
||||
|
||||
return (
|
||||
<SelectSsrValueContext.Provider value={{ items, value }}>
|
||||
<SelectPrimitive.Root data-slot="select" value={value} {...props}>
|
||||
{children}
|
||||
</SelectPrimitive.Root>
|
||||
</SelectSsrValueContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
function SelectValue({ children, ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
const context = React.useContext(SelectSsrValueContext);
|
||||
const resolvedChildren = children ?? (context?.value ? context.items.get(context.value) : undefined);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Value data-slot="select-value" {...props}>
|
||||
{resolvedChildren}
|
||||
</SelectPrimitive.Value>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
|
|
@ -29,7 +84,7 @@ function SelectTrigger({
|
|||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&+select[aria-hidden=true]]:hidden [&:has(+select[aria-hidden=true]:last-child)]:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import {
|
||||
DEFAULT_TIME_FORMAT,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatDateWithMonth,
|
||||
|
|
@ -64,4 +65,38 @@ describe("datetime formatters", () => {
|
|||
test("formats calendar values with an explicit locale and timezone", () => {
|
||||
expect(formatShortDateTime(sampleDate, { locale: "en-US", timeZone: "UTC" })).toBe("1/10, 2:30 PM");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["MM/DD/YYYY", "1/10/2026"],
|
||||
["DD/MM/YYYY", "10/1/2026"],
|
||||
["YYYY/MM/DD", "2026/1/10"],
|
||||
] as const)("formats numeric dates with %s order", (dateFormat, expected) => {
|
||||
expect(formatDate(sampleDate, { locale: "en-US", timeZone: "UTC", dateFormat })).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["MM/DD/YYYY", "Jan 10, 2026"],
|
||||
["DD/MM/YYYY", "10 Jan 2026"],
|
||||
["YYYY/MM/DD", "2026 Jan 10"],
|
||||
] as const)("formats month dates with %s order", (dateFormat, expected) => {
|
||||
expect(formatDateWithMonth(sampleDate, { locale: "en-US", timeZone: "UTC", dateFormat })).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[DEFAULT_TIME_FORMAT, "2:30 PM"],
|
||||
["24h", "14:30"],
|
||||
] as const)("formats times with %s clock", (timeFormat, expected) => {
|
||||
expect(formatTime(sampleDate, { locale: "en-US", timeZone: "UTC", timeFormat })).toBe(expected);
|
||||
});
|
||||
|
||||
test("formats combined values with custom date and time preferences", () => {
|
||||
expect(
|
||||
formatDateTime(sampleDate, {
|
||||
locale: "en-US",
|
||||
timeZone: "UTC",
|
||||
dateFormat: "DD/MM/YYYY",
|
||||
timeFormat: "24h",
|
||||
}),
|
||||
).toBe("10/1/2026, 14:30");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,9 +4,31 @@ import { Route as RootRoute } from "~/routes/__root";
|
|||
|
||||
export type DateInput = Date | string | number | null | undefined;
|
||||
|
||||
export const DATE_FORMATS = ["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"] as const;
|
||||
export type DateFormatPreference = (typeof DATE_FORMATS)[number];
|
||||
export const DEFAULT_DATE_FORMAT: DateFormatPreference = "MM/DD/YYYY";
|
||||
|
||||
export const TIME_FORMATS = ["12h", "24h"] as const;
|
||||
export type TimeFormatPreference = (typeof TIME_FORMATS)[number];
|
||||
export const DEFAULT_TIME_FORMAT: TimeFormatPreference = "12h";
|
||||
|
||||
const DATE_PART_ORDERS = {
|
||||
"MM/DD/YYYY": ["month", "day", "year"],
|
||||
"DD/MM/YYYY": ["day", "month", "year"],
|
||||
"YYYY/MM/DD": ["year", "month", "day"],
|
||||
} as const;
|
||||
|
||||
const SHORT_DATE_PART_ORDERS = {
|
||||
"MM/DD/YYYY": ["month", "day"],
|
||||
"DD/MM/YYYY": ["day", "month"],
|
||||
"YYYY/MM/DD": ["month", "day"],
|
||||
} as const;
|
||||
|
||||
type DateFormatOptions = {
|
||||
locale?: string | string[];
|
||||
timeZone?: string;
|
||||
dateFormat?: DateFormatPreference;
|
||||
timeFormat?: TimeFormatPreference;
|
||||
};
|
||||
|
||||
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
|
||||
|
|
@ -29,71 +51,98 @@ function getDateTimeFormat(
|
|||
});
|
||||
}
|
||||
|
||||
function getRequiredPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes) {
|
||||
const value = parts.find((part) => part.type === type)?.value;
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Missing ${type} in formatted date`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatConfiguredDate(date: Date, options: DateFormatOptions, includeYear: boolean) {
|
||||
const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT;
|
||||
const safeDateFormat = DATE_FORMATS.includes(dateFormat) ? dateFormat : DEFAULT_DATE_FORMAT;
|
||||
const parts = getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).formatToParts(date);
|
||||
const values = {
|
||||
month: getRequiredPart(parts, "month"),
|
||||
day: getRequiredPart(parts, "day"),
|
||||
year: getRequiredPart(parts, "year"),
|
||||
};
|
||||
const order = includeYear ? DATE_PART_ORDERS[safeDateFormat] : SHORT_DATE_PART_ORDERS[safeDateFormat];
|
||||
|
||||
return order.map((part) => values[part]).join("/");
|
||||
}
|
||||
|
||||
function formatConfiguredDateWithMonth(date: Date, options: DateFormatOptions) {
|
||||
const dateFormat = options.dateFormat ?? DEFAULT_DATE_FORMAT;
|
||||
const parts = getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).formatToParts(date);
|
||||
const month = getRequiredPart(parts, "month");
|
||||
const day = getRequiredPart(parts, "day");
|
||||
const year = getRequiredPart(parts, "year");
|
||||
|
||||
if (dateFormat === "DD/MM/YYYY") {
|
||||
return `${day} ${month} ${year}`;
|
||||
}
|
||||
|
||||
if (dateFormat === "YYYY/MM/DD") {
|
||||
return `${year} ${month} ${day}`;
|
||||
}
|
||||
|
||||
return `${month} ${day}, ${year}`;
|
||||
}
|
||||
|
||||
function formatConfiguredTime(date: Date, options: DateFormatOptions) {
|
||||
return getDateTimeFormat(options.locale, options.timeZone, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: (options.timeFormat ?? DEFAULT_TIME_FORMAT) === "12h",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
// 1/10/2026, 2:30 PM
|
||||
export function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) =>
|
||||
getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}).format(validDate),
|
||||
return formatValidDate(
|
||||
date,
|
||||
(validDate) => `${formatConfiguredDate(validDate, options, true)}, ${formatConfiguredTime(validDate, options)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Jan 10, 2026
|
||||
export function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) =>
|
||||
getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(validDate),
|
||||
);
|
||||
return formatValidDate(date, (validDate) => formatConfiguredDateWithMonth(validDate, options));
|
||||
}
|
||||
|
||||
// 1/10/2026
|
||||
export function formatDate(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) =>
|
||||
getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(validDate),
|
||||
);
|
||||
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, true));
|
||||
}
|
||||
|
||||
// 1/10
|
||||
export function formatShortDate(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) =>
|
||||
getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
}).format(validDate),
|
||||
);
|
||||
return formatValidDate(date, (validDate) => formatConfiguredDate(validDate, options, false));
|
||||
}
|
||||
|
||||
// 1/10, 2:30 PM
|
||||
export function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) =>
|
||||
getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}).format(validDate),
|
||||
return formatValidDate(
|
||||
date,
|
||||
(validDate) => `${formatConfiguredDate(validDate, options, false)}, ${formatConfiguredTime(validDate, options)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2:30 PM
|
||||
export function formatTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) =>
|
||||
getDateTimeFormat(options.locale, options.timeZone, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}).format(validDate),
|
||||
);
|
||||
return formatValidDate(date, (validDate) => formatConfiguredTime(validDate, options));
|
||||
}
|
||||
|
||||
// 5 minutes ago
|
||||
|
|
@ -113,23 +162,24 @@ export function formatTimeAgo(date: DateInput, now = Date.now()): string {
|
|||
}
|
||||
|
||||
export function useTimeFormat() {
|
||||
const { locale, timeZone, now } = RootRoute.useLoaderData();
|
||||
const { locale, timeZone, dateFormat, timeFormat, now } = RootRoute.useLoaderData();
|
||||
const [currentNow, setCurrentNow] = useState(now);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentNow(Date.now());
|
||||
const nextNow = Date.now();
|
||||
setCurrentNow(nextNow === now ? now : nextNow);
|
||||
}, [now]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
formatDateTime: (date: DateInput) => formatDateTime(date, { locale, timeZone }),
|
||||
formatDateWithMonth: (date: DateInput) => formatDateWithMonth(date, { locale, timeZone }),
|
||||
formatDate: (date: DateInput) => formatDate(date, { locale, timeZone }),
|
||||
formatShortDate: (date: DateInput) => formatShortDate(date, { locale, timeZone }),
|
||||
formatShortDateTime: (date: DateInput) => formatShortDateTime(date, { locale, timeZone }),
|
||||
formatTime: (date: DateInput) => formatTime(date, { locale, timeZone }),
|
||||
formatDateTime: (date: DateInput) => formatDateTime(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatDateWithMonth: (date: DateInput) => formatDateWithMonth(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatDate: (date: DateInput) => formatDate(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatShortDate: (date: DateInput) => formatShortDate(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatShortDateTime: (date: DateInput) => formatShortDateTime(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatTime: (date: DateInput) => formatTime(date, { locale, timeZone, dateFormat, timeFormat }),
|
||||
formatTimeAgo: (date: DateInput) => formatTimeAgo(date, currentNow),
|
||||
}),
|
||||
[locale, timeZone, currentNow],
|
||||
[locale, timeZone, currentNow, dateFormat, timeFormat],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { useState } from "react";
|
|||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { normalizeUsername } from "~/lib/username";
|
||||
import { z } from "zod";
|
||||
import { DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from "~/client/lib/datetime";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
|
|
@ -60,6 +61,8 @@ export function OnboardingPage() {
|
|||
|
||||
const { data, error } = await authClient.signUp.email({
|
||||
username: normalizeUsername(values.username),
|
||||
dateFormat: DEFAULT_DATE_FORMAT,
|
||||
timeFormat: DEFAULT_TIME_FORMAT,
|
||||
password: values.password,
|
||||
email: values.email.toLowerCase().trim(),
|
||||
name: values.username,
|
||||
|
|
|
|||
|
|
@ -17,10 +17,19 @@ import {
|
|||
} from "~/client/components/ui/dialog";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
import {
|
||||
DATE_FORMATS,
|
||||
type DateFormatPreference,
|
||||
formatDateTime,
|
||||
TIME_FORMATS,
|
||||
type TimeFormatPreference,
|
||||
} from "~/client/lib/datetime";
|
||||
import { logger } from "~/client/lib/logger";
|
||||
import { type AppContext } from "~/context";
|
||||
import { Route as RootRoute } from "~/routes/__root";
|
||||
import { TwoFactorSection } from "../components/two-factor-section";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
|
||||
|
|
@ -41,6 +50,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
||||
const [downloadPassword, setDownloadPassword] = useState("");
|
||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||
const { locale, dateFormat, timeFormat } = RootRoute.useLoaderData();
|
||||
|
||||
const { tab } = useSearch({ from: "/(dashboard)/settings/" });
|
||||
const activeTab = tab || "account";
|
||||
|
|
@ -48,6 +58,12 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
const navigate = useNavigate();
|
||||
const { activeMember, activeOrganization } = useOrganizationContext();
|
||||
const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin";
|
||||
const dateTimePreview = formatDateTime("2026-01-10T14:30:00.000Z", {
|
||||
locale,
|
||||
timeZone: "UTC",
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
});
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authClient.signOut({
|
||||
|
|
@ -141,6 +157,42 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
});
|
||||
};
|
||||
|
||||
const handleDateTimeFormatChange = async (
|
||||
nextDateFormat: DateFormatPreference,
|
||||
nextTimeFormat: TimeFormatPreference,
|
||||
) => {
|
||||
await authClient.updateUser({
|
||||
dateFormat: nextDateFormat,
|
||||
timeFormat: nextTimeFormat,
|
||||
fetchOptions: {
|
||||
onError: ({ error }) => {
|
||||
toast.error("Failed to update date and time format", {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
window.location.reload();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDateFormatChange = async (nextDateFormat: DateFormatPreference) => {
|
||||
if (nextDateFormat === dateFormat) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDateTimeFormatChange(nextDateFormat, timeFormat);
|
||||
};
|
||||
|
||||
const handleTimeFormatChange = async (nextTimeFormat: TimeFormatPreference) => {
|
||||
if (nextTimeFormat === timeFormat) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDateTimeFormatChange(dateFormat, nextTimeFormat);
|
||||
};
|
||||
|
||||
const onTabChange = (value: string) => {
|
||||
void navigate({ to: ".", search: () => ({ tab: value }) });
|
||||
};
|
||||
|
|
@ -174,6 +226,59 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
|||
</div>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-t border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<SettingsIcon className="size-5" />
|
||||
Date and Time Format
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">
|
||||
Choose how dates and times are shown throughout the app
|
||||
</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date-format">Date format</Label>
|
||||
<Select
|
||||
value={dateFormat}
|
||||
onValueChange={(value) => void handleDateFormatChange(value as DateFormatPreference)}
|
||||
>
|
||||
<SelectTrigger id="date-format">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_FORMATS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="time-format">Time format</Label>
|
||||
<Select
|
||||
value={timeFormat}
|
||||
onValueChange={(value) => void handleTimeFormatChange(value as TimeFormatPreference)}
|
||||
>
|
||||
<SelectTrigger id="time-format">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_FORMATS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Preview: {dateTimePreview}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-t border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="size-5" />
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ type User = {
|
|||
username: string;
|
||||
name: string;
|
||||
hasDownloadedResticPassword: boolean;
|
||||
dateFormat: string;
|
||||
timeFormat: string;
|
||||
twoFactorEnabled?: boolean | null;
|
||||
role?: string | null | undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE `users_table` ADD `date_format` text DEFAULT 'MM/DD/YYYY' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `users_table` ADD `time_format` text DEFAULT '12h' NOT NULL;
|
||||
2301
app/drizzle/20260326180038_slimy_trish_tilby/snapshot.json
Normal file
2301
app/drizzle/20260326180038_slimy_trish_tilby/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,8 @@ import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-prov
|
|||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
||||
import { isAuthRoute } from "~/lib/auth-routes";
|
||||
import { auth } from "~/server/lib/auth";
|
||||
import type { DateFormatPreference, TimeFormatPreference } from "~/client/lib/datetime";
|
||||
|
||||
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const themeCookie = getCookie(THEME_COOKIE_NAME);
|
||||
|
|
@ -17,11 +19,15 @@ const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
|
|||
});
|
||||
|
||||
const fetchTimeConfig = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const acceptLanguage = getRequestHeaders().get("accept-language");
|
||||
const headers = getRequestHeaders();
|
||||
const acceptLanguage = headers.get("accept-language");
|
||||
const session = await auth.api.getSession({ headers });
|
||||
|
||||
return {
|
||||
locale: (acceptLanguage?.split(",")[0] || "en-US") as string,
|
||||
timeZone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
dateFormat: (session?.user.dateFormat ?? "MM/DD/YYYY") as DateFormatPreference,
|
||||
timeFormat: (session?.user.timeFormat ?? "12h") as TimeFormatPreference,
|
||||
now: Date.now(),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export const usersTable = sqliteTable("users_table", {
|
|||
username: text().notNull().unique(),
|
||||
passwordHash: text("password_hash"),
|
||||
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false),
|
||||
dateFormat: text("date_format").notNull().default("MM/DD/YYYY"),
|
||||
timeFormat: text("time_format").notNull().default("12h"),
|
||||
createdAt: int("created_at", { mode: "timestamp_ms" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
|
|
|
|||
|
|
@ -138,6 +138,14 @@ export const auth = betterAuth({
|
|||
type: "boolean",
|
||||
returned: true,
|
||||
},
|
||||
dateFormat: {
|
||||
type: "string",
|
||||
returned: true,
|
||||
},
|
||||
timeFormat: {
|
||||
type: "string",
|
||||
returned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue