refactor(client): configure time formatting with timezone from server
This commit is contained in:
parent
8acca9ef4a
commit
45ae281a6c
21 changed files with 141 additions and 55 deletions
|
|
@ -3,7 +3,7 @@ import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { formatDateTime } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
interface CronInputProps {
|
interface CronInputProps {
|
||||||
|
|
@ -13,6 +13,7 @@ interface CronInputProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CronInput({ value, onChange, error }: CronInputProps) {
|
export function CronInput({ value, onChange, error }: CronInputProps) {
|
||||||
|
const { formatDateTime } = useTimeFormat();
|
||||||
const { isValid, nextRuns, parseError } = useMemo(() => {
|
const { isValid, nextRuns, parseError } = useMemo(() => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return { isValid: false, nextRuns: [], parseError: null };
|
return { isValid: false, nextRuns: [], parseError: null };
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-b
|
||||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
|
|
||||||
type LocalFileBrowserProps = FileBrowserUiProps & {
|
type LocalFileBrowserProps = FileBrowserUiProps & {
|
||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
|
|
@ -26,7 +27,7 @@ export const LocalFileBrowser = ({ initialPath = "/", enabled = true, ...uiProps
|
||||||
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
||||||
},
|
},
|
||||||
prefetchFolder: (path) => {
|
prefetchFolder: (path) => {
|
||||||
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
|
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } })).catch((e) => logger.error(e));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-b
|
||||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
|
|
||||||
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
|
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
|
|
@ -79,12 +80,14 @@ export const SnapshotTreeBrowser = ({
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
prefetchFolder: (path) => {
|
prefetchFolder: (path) => {
|
||||||
void queryClient.prefetchQuery(
|
void queryClient
|
||||||
listSnapshotFilesOptions({
|
.prefetchQuery(
|
||||||
path: { shortId: repositoryId, snapshotId },
|
listSnapshotFilesOptions({
|
||||||
query: { path, offset: 0, limit: pageSize },
|
path: { shortId: repositoryId, snapshotId },
|
||||||
}),
|
query: { path, offset: 0, limit: pageSize },
|
||||||
);
|
}),
|
||||||
|
)
|
||||||
|
.catch((e) => logger.error(e));
|
||||||
},
|
},
|
||||||
pathTransform: {
|
pathTransform: {
|
||||||
strip: stripBasePath,
|
strip: stripBasePath,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { listFilesOptions } from "~/client/api-client/@tanstack/react-query.gen"
|
||||||
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
||||||
import { useFileBrowser, type FetchFolderResult } from "~/client/hooks/use-file-browser";
|
import { useFileBrowser, type FetchFolderResult } from "~/client/hooks/use-file-browser";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
|
|
||||||
type VolumeFileBrowserProps = FileBrowserUiProps & {
|
type VolumeFileBrowserProps = FileBrowserUiProps & {
|
||||||
volumeId: string;
|
volumeId: string;
|
||||||
|
|
@ -29,12 +30,14 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
prefetchFolder: (path) => {
|
prefetchFolder: (path) => {
|
||||||
void queryClient.prefetchQuery(
|
void queryClient
|
||||||
listFilesOptions({
|
.prefetchQuery(
|
||||||
path: { shortId: volumeId },
|
listFilesOptions({
|
||||||
query: { path },
|
path: { shortId: volumeId },
|
||||||
}),
|
query: { path },
|
||||||
);
|
}),
|
||||||
|
)
|
||||||
|
.catch((e) => logger.error(e));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -581,8 +581,10 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
type="button"
|
// oxlint-disable-next-line jsx_a11y/prefer-tag-over-role
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
|
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
|
||||||
style={{ paddingLeft }}
|
style={{ paddingLeft }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|
@ -591,7 +593,7 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<div className="truncate w-full flex items-center gap-2">{children}</div>
|
<div className="truncate w-full flex items-center gap-2">{children}</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
|
||||||
import { ScrollArea } from "~/client/components/ui/scroll-area";
|
import { ScrollArea } from "~/client/components/ui/scroll-area";
|
||||||
import { formatDate } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import type { UpdateInfoDto } from "~/server/modules/system/system.dto";
|
import type { UpdateInfoDto } from "~/server/modules/system/system.dto";
|
||||||
|
|
||||||
interface ReleaseNotesDialogProps {
|
interface ReleaseNotesDialogProps {
|
||||||
|
|
@ -12,6 +12,8 @@ interface ReleaseNotesDialogProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) {
|
export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) {
|
||||||
|
const { formatDate } = useTimeFormat();
|
||||||
|
|
||||||
if (!updates) return null;
|
if (!updates) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/client/components/ui/dialog";
|
} from "~/client/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { formatDuration } from "~/utils/utils";
|
import { formatDuration } from "~/utils/utils";
|
||||||
import { formatDateTime } from "~/client/lib/datetime";
|
|
||||||
import {
|
import {
|
||||||
deleteSnapshotsMutation,
|
deleteSnapshotsMutation,
|
||||||
listSnapshotsQueryKey,
|
listSnapshotsQueryKey,
|
||||||
|
|
@ -49,6 +49,7 @@ type Props = {
|
||||||
export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshotsQueryOptions }: Props) => {
|
export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshotsQueryOptions }: Props) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { formatDateTime } = useTimeFormat();
|
||||||
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
||||||
|
|
|
||||||
12
app/client/components/time-ago.tsx
Normal file
12
app/client/components/time-ago.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { useTimeFormat, type DateInput } from "~/client/lib/datetime";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
date: DateInput;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TimeAgo({ date, className }: Props) {
|
||||||
|
const { formatTimeAgo } = useTimeFormat();
|
||||||
|
|
||||||
|
return <span className={className}>{formatTimeAgo(date)}</span>;
|
||||||
|
}
|
||||||
|
|
@ -60,4 +60,8 @@ describe("datetime formatters", () => {
|
||||||
|
|
||||||
nowSpy.mockRestore();
|
nowSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("formats calendar values with an explicit locale and timezone", () => {
|
||||||
|
expect(formatShortDateTime(sampleDate, { locale: "en-US", timeZone: "UTC" })).toBe("1/10, 2:30 PM");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import { formatDistanceToNow, isValid } from "date-fns";
|
import { formatDistanceToNow, isValid } from "date-fns";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Route as RootRoute } from "~/routes/__root";
|
||||||
|
|
||||||
type DateInput = Date | string | number | null | undefined;
|
export type DateInput = Date | string | number | null | undefined;
|
||||||
|
|
||||||
const getLocales = () => (typeof navigator !== "undefined" ? navigator.languages : undefined);
|
type DateFormatOptions = {
|
||||||
|
locale?: string | string[];
|
||||||
|
timeZone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
|
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
|
||||||
if (!date) return "Never";
|
if (!date) return "Never";
|
||||||
|
|
@ -13,10 +18,21 @@ function formatValidDate(date: DateInput, formatter: (date: Date) => string): st
|
||||||
return formatter(parsedDate);
|
return formatter(parsedDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDateTimeFormat(
|
||||||
|
locale: DateFormatOptions["locale"],
|
||||||
|
timeZone: DateFormatOptions["timeZone"],
|
||||||
|
options: Intl.DateTimeFormatOptions,
|
||||||
|
) {
|
||||||
|
return Intl.DateTimeFormat(locale, {
|
||||||
|
...options,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 1/10/2026, 2:30 PM
|
// 1/10/2026, 2:30 PM
|
||||||
export function formatDateTime(date: DateInput): string {
|
export function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(getLocales(), {
|
getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|
@ -27,9 +43,9 @@ export function formatDateTime(date: DateInput): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jan 10, 2026
|
// Jan 10, 2026
|
||||||
export function formatDateWithMonth(date: DateInput): string {
|
export function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(getLocales(), {
|
getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|
@ -38,9 +54,9 @@ export function formatDateWithMonth(date: DateInput): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1/10/2026
|
// 1/10/2026
|
||||||
export function formatDate(date: DateInput): string {
|
export function formatDate(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(getLocales(), {
|
getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|
@ -49,9 +65,9 @@ export function formatDate(date: DateInput): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1/10
|
// 1/10
|
||||||
export function formatShortDate(date: DateInput): string {
|
export function formatShortDate(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(getLocales(), {
|
getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(validDate),
|
}).format(validDate),
|
||||||
|
|
@ -59,9 +75,9 @@ export function formatShortDate(date: DateInput): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1/10, 2:30 PM
|
// 1/10, 2:30 PM
|
||||||
export function formatShortDateTime(date: DateInput): string {
|
export function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(getLocales(), {
|
getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
|
|
@ -71,9 +87,9 @@ export function formatShortDateTime(date: DateInput): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2:30 PM
|
// 2:30 PM
|
||||||
export function formatTime(date: DateInput): string {
|
export function formatTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(getLocales(), {
|
getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "numeric",
|
minute: "numeric",
|
||||||
}).format(validDate),
|
}).format(validDate),
|
||||||
|
|
@ -91,3 +107,20 @@ export function formatTimeAgo(date: DateInput): string {
|
||||||
return timeAgo.replace("about ", "").replace("over ", "").replace("almost ", "").replace("less than ", "");
|
return timeAgo.replace("about ", "").replace("over ", "").replace("almost ", "").replace("less than ", "");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useTimeFormat() {
|
||||||
|
const { locale, timeZone } = RootRoute.useLoaderData();
|
||||||
|
|
||||||
|
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 }),
|
||||||
|
formatTimeAgo,
|
||||||
|
}),
|
||||||
|
[locale, timeZone],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { CalendarClock, Database, HardDrive } from "lucide-react";
|
import { CalendarClock, Database, HardDrive } from "lucide-react";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { TimeAgo } from "~/client/components/time-ago";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import type { BackupSchedule } from "~/client/lib/types";
|
import type { BackupSchedule } from "~/client/lib/types";
|
||||||
import { BackupStatusDot } from "./backup-status-dot";
|
import { BackupStatusDot } from "./backup-status-dot";
|
||||||
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
||||||
|
const { formatShortDateTime } = useTimeFormat();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={schedule.shortId} to="/backups/$backupId" params={{ backupId: schedule.shortId }}>
|
<Link key={schedule.shortId} to="/backups/$backupId" params={{ backupId: schedule.shortId }}>
|
||||||
<Card interactive key={schedule.shortId} className="flex flex-col h-full">
|
<Card interactive key={schedule.shortId} className="flex flex-col h-full">
|
||||||
|
|
@ -42,7 +45,7 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
||||||
<div className="flex items-center text-sm gap-2">
|
<div className="flex items-center text-sm gap-2">
|
||||||
<span className="text-muted-foreground shrink-0">Last backup</span>
|
<span className="text-muted-foreground shrink-0">Last backup</span>
|
||||||
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
|
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
|
||||||
<span className="text-foreground font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span>
|
<TimeAgo date={schedule.lastBackupAt} className="text-foreground font-mono text-sm shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm gap-2">
|
<div className="flex items-center text-sm gap-2">
|
||||||
<span className="text-muted-foreground shrink-0">Next backup</span>
|
<span className="text-muted-foreground shrink-0">Next backup</span>
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,12 @@ import type { BackupSchedule } from "~/client/lib/types";
|
||||||
import { BackupProgressCard } from "./backup-progress-card";
|
import { BackupProgressCard } from "./backup-progress-card";
|
||||||
import { getBackupProgressOptions, runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getBackupProgressOptions, runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { handleRepositoryError } from "~/client/lib/errors";
|
import { handleRepositoryError } from "~/client/lib/errors";
|
||||||
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
|
||||||
|
import { TimeAgo } from "~/client/components/time-ago";
|
||||||
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -35,6 +36,7 @@ type Props = {
|
||||||
export const ScheduleSummary = (props: Props) => {
|
export const ScheduleSummary = (props: Props) => {
|
||||||
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
|
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
|
||||||
props;
|
props;
|
||||||
|
const { formatShortDateTime } = useTimeFormat();
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [showForgetConfirm, setShowForgetConfirm] = useState(false);
|
const [showForgetConfirm, setShowForgetConfirm] = useState(false);
|
||||||
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
||||||
|
|
@ -182,7 +184,7 @@ export const ScheduleSummary = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-muted-foreground">Last backup</p>
|
<p className="text-xs uppercase text-muted-foreground">Last backup</p>
|
||||||
<p className="font-medium">{formatTimeAgo(schedule.lastBackupAt)}</p>
|
<TimeAgo date={schedule.lastBackupAt} className="font-medium" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-muted-foreground">Next backup</p>
|
<p className="text-xs uppercase text-muted-foreground">Next backup</p>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { Button, buttonVariants } from "~/client/components/ui/button";
|
import { Button, buttonVariants } from "~/client/components/ui/button";
|
||||||
import type { Snapshot } from "~/client/lib/types";
|
import type { Snapshot } from "~/client/lib/types";
|
||||||
import { formatDateTime } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
|
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
|
||||||
|
|
@ -62,6 +62,7 @@ const TreeBrowserFallback = () => (
|
||||||
|
|
||||||
export const SnapshotFileBrowser = (props: Props) => {
|
export const SnapshotFileBrowser = (props: Props) => {
|
||||||
const { snapshot, repositoryId, backupId, basePath, onDeleteSnapshot, isDeletingSnapshot } = props;
|
const { snapshot, repositoryId, backupId, basePath, onDeleteSnapshot, isDeletingSnapshot } = props;
|
||||||
|
const { formatDateTime } = useTimeFormat();
|
||||||
|
|
||||||
const scheduleShortId = !basePath ? backupId || snapshot.tags?.[0] : undefined;
|
const scheduleShortId = !basePath ? backupId || snapshot.tags?.[0] : undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { ListSnapshotsResponse } from "~/client/api-client";
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
import { ByteSize } from "~/client/components/bytes-size";
|
||||||
import { Card, CardContent } from "~/client/components/ui/card";
|
import { Card, CardContent } from "~/client/components/ui/card";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { formatDateWithMonth, formatShortDate, formatTime } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { RetentionCategoryBadges } from "~/client/components/retention-category-badges";
|
import { RetentionCategoryBadges } from "~/client/components/retention-category-badges";
|
||||||
|
|
||||||
|
|
@ -45,6 +45,7 @@ interface Props {
|
||||||
export const SnapshotTimeline = (props: Props) => {
|
export const SnapshotTimeline = (props: Props) => {
|
||||||
const { snapshots, snapshotId, loading, onSnapshotSelect, error, initialSortOrder = "asc" } = props;
|
const { snapshots, snapshotId, loading, onSnapshotSelect, error, initialSortOrder = "asc" } = props;
|
||||||
const selectedRef = useRef<HTMLButtonElement>(null);
|
const selectedRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const { formatDateWithMonth, formatShortDate, formatTime } = useTimeFormat();
|
||||||
const [sortOrder, setSortOrder] = useState<SnapshotTimelineSortOrder>(initialSortOrder);
|
const [sortOrder, setSortOrder] = useState<SnapshotTimelineSortOrder>(initialSortOrder);
|
||||||
const sortedSnapshots = useMemo(() => getSortedSnapshots(snapshots, sortOrder), [snapshots, sortOrder]);
|
const sortedSnapshots = useMemo(() => getSortedSnapshots(snapshots, sortOrder), [snapshots, sortOrder]);
|
||||||
const snapshotRange = useMemo(() => getSnapshotRange(snapshots), [snapshots]);
|
const snapshotRange = useMemo(() => getSnapshotRange(snapshots), [snapshots]);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
|
||||||
import { formatDateTime } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { cn, safeJsonParse } from "~/client/lib/utils";
|
import { cn, safeJsonParse } from "~/client/lib/utils";
|
||||||
import { Card, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardTitle } from "~/client/components/ui/card";
|
||||||
|
|
||||||
|
|
@ -23,6 +23,8 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DoctorReport = ({ result, repositoryStatus }: Props) => {
|
export const DoctorReport = ({ result, repositoryStatus }: Props) => {
|
||||||
|
const { formatDateTime } = useTimeFormat();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="px-6 py-6 flex flex-col gap-4 h-full">
|
<Card className="px-6 py-6 flex flex-col gap-4 h-full">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type { GetSnapshotDetailsResponse } from "~/client/api-client/types.gen";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
|
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
|
||||||
import { formatDateTime } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { BackupSummaryCard } from "~/client/components/backup-summary-card";
|
import { BackupSummaryCard } from "~/client/components/backup-summary-card";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Database } from "lucide-react";
|
import { Database } from "lucide-react";
|
||||||
|
|
@ -58,6 +58,7 @@ type Props = {
|
||||||
|
|
||||||
export function SnapshotDetailsPage({ repositoryId, snapshotId, initialSnapshot }: Props) {
|
export function SnapshotDetailsPage({ repositoryId, snapshotId, initialSnapshot }: Props) {
|
||||||
const [showAllPaths, setShowAllPaths] = useState(false);
|
const [showAllPaths, setShowAllPaths] = useState(false);
|
||||||
|
const { formatDateTime } = useTimeFormat();
|
||||||
|
|
||||||
const { data: repository } = useSuspenseQuery({
|
const { data: repository } = useSuspenseQuery({
|
||||||
...getRepositoryOptions({ path: { shortId: repositoryId } }),
|
...getRepositoryOptions({ path: { shortId: repositoryId } }),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
} from "~/client/components/ui/alert-dialog";
|
} from "~/client/components/ui/alert-dialog";
|
||||||
import type { Repository } from "~/client/lib/types";
|
import type { Repository } from "~/client/lib/types";
|
||||||
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
|
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
|
||||||
import { formatDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
|
||||||
import {
|
import {
|
||||||
cancelDoctorMutation,
|
cancelDoctorMutation,
|
||||||
deleteRepositoryMutation,
|
deleteRepositoryMutation,
|
||||||
|
|
@ -23,6 +22,8 @@ import {
|
||||||
unlockRepositoryMutation,
|
unlockRepositoryMutation,
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
||||||
|
import { TimeAgo } from "~/client/components/time-ago";
|
||||||
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { DoctorReport } from "../components/doctor-report";
|
import { DoctorReport } from "../components/doctor-report";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
@ -41,6 +42,7 @@ const getEffectiveLocalPath = (repository: Repository): string | null => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) => {
|
export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) => {
|
||||||
|
const { formatDateTime } = useTimeFormat();
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
|
@ -206,7 +208,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
|
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
|
||||||
<p className="text-sm">{formatTimeAgo(repository.lastChecked)}</p>
|
<TimeAgo date={repository.lastChecked} className="text-sm" />
|
||||||
</div>
|
</div>
|
||||||
{hasLocalPath && (
|
{hasLocalPath && (
|
||||||
<div className="flex flex-col gap-1 @medium:col-span-2">
|
<div className="flex flex-col gap-1 @medium:col-span-2">
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
|
||||||
import { Switch } from "~/client/components/ui/switch";
|
import { Switch } from "~/client/components/ui/switch";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||||
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
||||||
import { formatDateWithMonth } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { getOrigin } from "~/client/functions/get-origin";
|
import { getOrigin } from "~/client/functions/get-origin";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
@ -47,6 +47,7 @@ export function SsoSettingsSection({ initialSettings, initialOrigin }: Props) {
|
||||||
const { data } = useSuspenseQuery({ queryKey: ["app-origin"], queryFn: originQuery, initialData: initialOrigin });
|
const { data } = useSuspenseQuery({ queryKey: ["app-origin"], queryFn: originQuery, initialData: initialOrigin });
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { formatDateWithMonth } = useTimeFormat();
|
||||||
const { activeOrganization } = useOrganizationContext();
|
const { activeOrganization } = useOrganizationContext();
|
||||||
const [inviteEmail, setInviteEmail] = useState("");
|
const [inviteEmail, setInviteEmail] = useState("");
|
||||||
const [inviteRole, setInviteRole] = useState<InvitationRole>("member");
|
const [inviteRole, setInviteRole] = useState<InvitationRole>("member");
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import { healthCheckVolumeMutation, updateVolumeMutation } from "~/client/api-cl
|
||||||
import { OnOff } from "~/client/components/onoff";
|
import { OnOff } from "~/client/components/onoff";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { formatTimeAgo } from "~/client/lib/datetime";
|
|
||||||
import type { Volume } from "~/client/lib/types";
|
import type { Volume } from "~/client/lib/types";
|
||||||
|
import { TimeAgo } from "~/client/components/time-ago";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
volume: Volume;
|
volume: Volume;
|
||||||
|
|
@ -53,9 +53,11 @@ export const HealthchecksCard = ({ volume }: Props) => {
|
||||||
{volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>}
|
{volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>}
|
||||||
{volume.status === "mounted" && <span className="text-md text-success">Healthy</span>}
|
{volume.status === "mounted" && <span className="text-md text-success">Healthy</span>}
|
||||||
{volume.status !== "unmounted" && (
|
{volume.status !== "unmounted" && (
|
||||||
<span className="text-xs text-muted-foreground mb-4">Checked {formatTimeAgo(volume.lastHealthCheck)}</span>
|
<span className="text-xs text-muted-foreground mb-4">
|
||||||
|
Checked <TimeAgo date={volume.lastHealthCheck} />
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="flex justify-between items-center gap-2">
|
<div className="flex justify-between items-center gap-2">
|
||||||
<span className="text-sm">Remount on error</span>
|
<span className="text-sm">Remount on error</span>
|
||||||
<OnOff
|
<OnOff
|
||||||
isOn={volume.autoRemount}
|
isOn={volume.autoRemount}
|
||||||
|
|
@ -69,7 +71,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
|
||||||
enabledLabel="Enabled"
|
enabledLabel="Enabled"
|
||||||
disabledLabel="Paused"
|
disabledLabel="Paused"
|
||||||
/>
|
/>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{volume.status !== "unmounted" && (
|
{volume.status !== "unmounted" && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,9 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||||
|
|
||||||
const deleteVol = useMutation({
|
const deleteVol = useMutation({
|
||||||
...deleteVolumeMutation(),
|
...deleteVolumeMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
toast.success("Volume deleted successfully");
|
toast.success("Volume deleted successfully");
|
||||||
void navigate({ to: "/volumes" });
|
await navigate({ to: "/volumes" });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to delete volume", {
|
toast.error("Failed to delete volume", {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useServerEvents } from "~/client/hooks/use-server-events";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-provider";
|
import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-provider";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getCookie } from "@tanstack/react-start/server";
|
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
||||||
import { isAuthRoute } from "~/lib/auth-routes";
|
import { isAuthRoute } from "~/lib/auth-routes";
|
||||||
|
|
||||||
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
|
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
|
|
@ -16,13 +16,22 @@ const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
return (themeCookie === "light" ? "light" : "dark") as "light" | "dark";
|
return (themeCookie === "light" ? "light" : "dark") as "light" | "dark";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fetchTimeConfig = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
|
const acceptLanguage = getRequestHeaders().get("accept-language");
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale: (acceptLanguage?.split(",")[0] || "en-US") as string,
|
||||||
|
timeZone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||||
server: {
|
server: {
|
||||||
middleware: [apiClientMiddleware],
|
middleware: [apiClientMiddleware],
|
||||||
},
|
},
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
const theme = await fetchTheme();
|
const [theme, timeConfig] = await Promise.all([fetchTheme(), fetchTimeConfig()]);
|
||||||
return { theme };
|
return { theme, ...timeConfig };
|
||||||
},
|
},
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [{ title: "Zerobyte - Open Source Backup Solution" }],
|
meta: [{ title: "Zerobyte - Open Source Backup Solution" }],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue