mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
working on restore form.
This commit is contained in:
@@ -37,3 +37,5 @@ next-env.d.ts
|
||||
|
||||
public/uploads/*
|
||||
!public/uploads/
|
||||
|
||||
/.env
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import React from "react";
|
||||
|
||||
import {LayoutAdmin} from "@/components/layout";
|
||||
import {currentUser} from "@/auth/current-user";
|
||||
import {redirect} from "next/navigation";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageDescription, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {AgentForm} from "@/components/wrappers/Agent/AgentForm/AgentForm";
|
||||
import {requiredCurrentUser} from "@/auth/current-user";
|
||||
import {prisma} from "@/prisma";
|
||||
@@ -9,14 +9,17 @@ import {notFound} from "next/navigation";
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
agentId: string;
|
||||
}>) {
|
||||
|
||||
const {agentId} = await props.params
|
||||
|
||||
const user = await requiredCurrentUser()
|
||||
const agent = await prisma.agent.findUnique({
|
||||
where: {
|
||||
id: props.params.agentId,
|
||||
id: agentId,
|
||||
}
|
||||
});
|
||||
|
||||
if (!agent){
|
||||
if (!agent) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@ import Link from "next/link";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
||||
|
||||
const {agentId} = await props.params
|
||||
|
||||
const agent = await prisma.agent.findUnique({
|
||||
where: {
|
||||
id: props.params.agentId,
|
||||
id: agentId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -112,7 +114,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
Last contact
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{agent.lastContact?.toDateString() ?? "Never connected"}
|
||||
{agent.lastContact?.toDateString() ?? "Never connected."}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {AgentForm} from "@/components/wrappers/Agent/AgentForm/AgentForm";
|
||||
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {requiredCurrentUser} from "@/auth/current-user";
|
||||
import {prisma} from "@/prisma";
|
||||
import {notFound} from "next/navigation";
|
||||
import {RestoreForm} from "@/components/wrappers/Database/RestoreForm";
|
||||
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
databaseId: string;
|
||||
}>) {
|
||||
|
||||
const {databaseId} = await props.params
|
||||
|
||||
|
||||
const user = await requiredCurrentUser()
|
||||
|
||||
const database = await prisma.database.findUnique({
|
||||
where: {
|
||||
id: databaseId,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const databases = await prisma.database.findMany({
|
||||
where: {
|
||||
dbms: database.dbms,
|
||||
}
|
||||
})
|
||||
|
||||
const backups = await prisma.backup.findMany({
|
||||
where: {
|
||||
status: "success",
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (!database) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>
|
||||
Restore {database.name}
|
||||
</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<RestoreForm databaseToRestore={database} databases={databases} backups={backups}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -48,7 +48,7 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.3.1",
|
||||
"input-otp": "^1.4.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
@@ -59,7 +59,7 @@
|
||||
"next-safe-action": "^7.4.3",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "19.0.0-rc-66855b96-20241106",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "19.0.0-rc-66855b96-20241106",
|
||||
"react-hook-form": "^7.53.1",
|
||||
"react-resizable-panels": "^2.1.6",
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `Agent` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `Backup` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `Database` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `Restauration` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `Settings` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Backup" DROP CONSTRAINT "Backup_database_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Database" DROP CONSTRAINT "Database_agent_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Restauration" DROP CONSTRAINT "Restauration_backup_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Restauration" DROP CONSTRAINT "Restauration_database_id_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Agent";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Backup";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Database";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Restauration";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Settings";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "agents" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"last_contact" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "agents_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "databases" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"backup_policy" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"agent_id" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "databases_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "backups" (
|
||||
"id" TEXT NOT NULL,
|
||||
"status" "Status" NOT NULL DEFAULT 'waiting',
|
||||
"file" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"database_id" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "backups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "restaurations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"status" "Status" NOT NULL DEFAULT 'waiting',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"backup_id" TEXT NOT NULL,
|
||||
"database_id" TEXT,
|
||||
|
||||
CONSTRAINT "restaurations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "settings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"storage" "TypeStorage" NOT NULL DEFAULT 'local',
|
||||
"name" TEXT NOT NULL,
|
||||
"s3EndPointUrl" TEXT,
|
||||
"s3AccessKeyId" TEXT,
|
||||
"s3SecretAccessKey" TEXT,
|
||||
"S3BucketName" TEXT,
|
||||
"smtpPassword" TEXT,
|
||||
"smtpFrom" TEXT,
|
||||
"smtpHost" TEXT,
|
||||
"smtpPort" TEXT,
|
||||
"smtpUser" TEXT,
|
||||
|
||||
CONSTRAINT "settings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "agents_slug_key" ON "agents"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "settings_name_key" ON "settings"("name");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "databases" ADD CONSTRAINT "databases_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "backups" ADD CONSTRAINT "backups_database_id_fkey" FOREIGN KEY ("database_id") REFERENCES "databases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "restaurations" ADD CONSTRAINT "restaurations_backup_id_fkey" FOREIGN KEY ("backup_id") REFERENCES "backups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "restaurations" ADD CONSTRAINT "restaurations_database_id_fkey" FOREIGN KEY ("database_id") REFERENCES "databases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -80,11 +80,20 @@ model Agent {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
databases Database[]
|
||||
|
||||
@@map("agents")
|
||||
}
|
||||
|
||||
enum Dbms {
|
||||
postgresql
|
||||
mysql
|
||||
mongodb
|
||||
}
|
||||
|
||||
model Database {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
dbms Dbms
|
||||
description String?
|
||||
backupPolicy String? @map("backup_policy")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -94,6 +103,8 @@ model Database {
|
||||
|
||||
backups Backup[]
|
||||
restaurations Restauration[]
|
||||
|
||||
@@map("databases")
|
||||
}
|
||||
|
||||
enum Status {
|
||||
@@ -113,6 +124,8 @@ model Backup {
|
||||
database Database @relation(fields: [databaseId], references: [id], onDelete: Cascade)
|
||||
|
||||
restaurations Restauration[]
|
||||
|
||||
@@map("backups")
|
||||
}
|
||||
|
||||
model Restauration {
|
||||
@@ -125,6 +138,8 @@ model Restauration {
|
||||
|
||||
databaseId String? @map("database_id")
|
||||
database Database? @relation(fields: [databaseId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("restaurations")
|
||||
}
|
||||
|
||||
enum TypeStorage {
|
||||
@@ -145,4 +160,6 @@ model Settings {
|
||||
smtpHost String?
|
||||
smtpPort String?
|
||||
smtpUser String?
|
||||
|
||||
@@map("settings")
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
|
||||
@@ -2,16 +2,13 @@ import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
CaretSortIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "@radix-ui/react-icons"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
@@ -31,7 +25,7 @@ const SelectTrigger = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<CaretSortIcon className="h-4 w-4 opacity-50" />
|
||||
<ChevronDownIcon className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
@@ -49,7 +43,7 @@ const SelectScrollUpButton = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
@@ -66,7 +60,7 @@ const SelectScrollDownButton = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
|
||||
@@ -20,7 +20,7 @@ export const AgentCard = (props: agentCardProps) => {
|
||||
<div className="">
|
||||
<CardHeader>{agent.name}</CardHeader>
|
||||
<CardContent>
|
||||
Last contact : {agent.lastContact?.toDateString() ?? "Never connected"}
|
||||
Last contact : {agent.lastContact?.toDateString() ?? "Never connected."}
|
||||
</CardContent>
|
||||
</div>
|
||||
<div className="mt-3 mr-3">
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import {useState} from "react";
|
||||
|
||||
import {DateTimePicker} from "@/components/wrappers/daytime-picker";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useZodForm
|
||||
} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {RestoreSchema} from "@/components/wrappers/Database/restore-form.schema";
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {ComboBox, ComboBoxFormItem} from "@/components/wrappers/combobox";
|
||||
import {Label} from "@/components/ui/label";
|
||||
|
||||
export type restoreFormProps = {
|
||||
databaseToRestore: any
|
||||
databases: any[]
|
||||
backups: any[]
|
||||
}
|
||||
|
||||
|
||||
export const RestoreForm = (props: restoreFormProps) => {
|
||||
|
||||
const {databaseToRestore, databases, backups} = props
|
||||
|
||||
console.log("bacups", backups)
|
||||
console.log("bacups", databaseToRestore)
|
||||
|
||||
const backupLocations = [
|
||||
{
|
||||
value: "remote-file",
|
||||
label: "Remote File",
|
||||
},
|
||||
{
|
||||
value: "desktop-file",
|
||||
label: "Desktop File",
|
||||
},
|
||||
]
|
||||
|
||||
const executionModes = [
|
||||
{
|
||||
value: "immediate",
|
||||
label: "Immediate",
|
||||
},
|
||||
{
|
||||
value: "scheduled",
|
||||
label: "Scheduled",
|
||||
},
|
||||
]
|
||||
|
||||
const [selectedDatabaseId, setSelectedDatabaseId] = useState(databaseToRestore.id)
|
||||
|
||||
|
||||
const filteredBackups = backups.filter(backup => backup.databaseId == selectedDatabaseId)
|
||||
|
||||
const defaultValues = {
|
||||
executionMode: "immediate",
|
||||
backupLocation: "remote-file",
|
||||
}
|
||||
|
||||
const form = useZodForm({
|
||||
schema: RestoreSchema,
|
||||
defaultValues: defaultValues
|
||||
});
|
||||
|
||||
const values = form.getValues()
|
||||
console.log("values", values)
|
||||
|
||||
|
||||
const mutation = useMutation({})
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={async (values) => {
|
||||
console.log(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="backupLocation"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Backup location</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{backupLocations.map((backupLocation, key) =>
|
||||
<SelectItem key={key} value={backupLocation.value}>
|
||||
{backupLocation.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{values.backupLocation == "remote-file" ?
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<Label>Database</Label>
|
||||
<ComboBox
|
||||
values={databases.map(database =>
|
||||
({"value": database.id, "label": database.name})
|
||||
)}
|
||||
onValueChange={setSelectedDatabaseId}
|
||||
defaultValue={selectedDatabaseId}
|
||||
searchField
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remoteBackup"
|
||||
render={({field}) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Remote backup</FormLabel>
|
||||
<ComboBoxFormItem
|
||||
values={filteredBackups.map(backup => ({
|
||||
"value": backup.id,
|
||||
"label": backup.createdAt.toString()
|
||||
})
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
: null}
|
||||
|
||||
{values.backupLocation == "desktop-file" ?
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="uploadedBackupFile"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>File</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="uploadedBackupFile" type="file" {...field}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/> : null}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="executionMode"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Execution mode</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{executionModes.map((executionMode, key) =>
|
||||
<SelectItem key={key} value={executionMode.value}>
|
||||
{executionMode.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{values.executionMode == "scheduled" ?
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scheduledDatetime"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Scheduled date</FormLabel>
|
||||
<FormControl>
|
||||
<DateTimePicker value={field.value} onChange={field.onChange}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/> : null}
|
||||
|
||||
<Button type="submit">Launch restore</Button>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {z} from "zod";
|
||||
|
||||
const ImmediateExecutionSchema = z.object({
|
||||
executionMode: z.literal('immediate'),
|
||||
});
|
||||
|
||||
const ScheduledExecutionSchema = z.object({
|
||||
executionMode: z.literal('scheduled'),
|
||||
scheduledDatetime: z.date(),
|
||||
});
|
||||
|
||||
const CommonBackupSchema = z.union([ImmediateExecutionSchema, ScheduledExecutionSchema]);
|
||||
|
||||
const RemoteBackupSchema = z.object({
|
||||
backupLocation: z.literal('remote-file'),
|
||||
}).merge(CommonBackupSchema);
|
||||
|
||||
const DesktopBackupSchema = z.object({
|
||||
backupLocation: z.literal('desktop-file'),
|
||||
uploadedBackupFile: z.instanceof(File),
|
||||
}).merge(CommonBackupSchema);
|
||||
|
||||
export const RestoreSchema = z.union([RemoteBackupSchema, DesktopBackupSchema]);
|
||||
export type RestoreType = z.infer<typeof RestoreSchema>;
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
import {Check, ChevronsUpDown} from "lucide-react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import {FormControl} from "@/components/ui/form";
|
||||
|
||||
export type comboBoxProps = {
|
||||
values: Array<{ value: string, label: string }>
|
||||
defaultValue?: string
|
||||
onValueChange?: any
|
||||
searchField?: boolean
|
||||
}
|
||||
|
||||
|
||||
export function ComboBox(props: comboBoxProps) {
|
||||
|
||||
|
||||
const {values: choices, defaultValue: defaultChoice = "", onValueChange, searchField = false} = props;
|
||||
|
||||
const [value, setValue] = useState(defaultChoice)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
{value
|
||||
? choices.find((choice) => choice.value === value)?.label
|
||||
: "Select choice..."}
|
||||
<ChevronsUpDown className="opacity-50"/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
{searchField ? <CommandInput placeholder="Search choice..." className="h-9"/> : null}
|
||||
<CommandList>
|
||||
<CommandEmpty>No choice found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{choices.map((choice) => (
|
||||
<CommandItem
|
||||
key={choice.value}
|
||||
value={choice.value}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue === value ? "" : currentValue)
|
||||
onValueChange(currentValue === value ? "" : currentValue)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{choice.label}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
value === choice.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export type comboBoxFormItemProps = comboBoxProps & {
|
||||
value: any
|
||||
name: any
|
||||
onChange: any
|
||||
};
|
||||
|
||||
export function ComboBoxFormItem(props: comboBoxFormItemProps) {
|
||||
|
||||
const {values: choices, searchField = false, value, name, onChange} = props;
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"w-[200px] justify-between",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{value
|
||||
? choices.find(
|
||||
(choice) => choice.value === value
|
||||
)?.label
|
||||
: `Select ${name}`}
|
||||
<ChevronsUpDown className="opacity-50"/>
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
{searchField ? <CommandInput placeholder="Search choice..." className="h-9"/> : null}
|
||||
<CommandList>
|
||||
<CommandEmpty>No {name} found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{choices.map((choice) => (
|
||||
<CommandItem
|
||||
value={choice.label}
|
||||
key={choice.value}
|
||||
onSelect={() => {
|
||||
onChange(choice.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{choice.label}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
choice.value === value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
import {Button, buttonVariants} from '@/components/ui/button';
|
||||
import type {CalendarProps} from '@/components/ui/calendar';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {add, format} from 'date-fns';
|
||||
import {type Locale, enUS} from 'date-fns/locale';
|
||||
import {Calendar as CalendarIcon, ChevronLeft, ChevronRight} from 'lucide-react';
|
||||
import {Clock} from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import {useImperativeHandle, useRef} from 'react';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {DayPicker} from 'react-day-picker';
|
||||
|
||||
// ---------- utils start ----------
|
||||
/**
|
||||
* regular expression to check for valid hour format (01-23)
|
||||
*/
|
||||
function isValidHour(value: string) {
|
||||
return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* regular expression to check for valid 12 hour format (01-12)
|
||||
*/
|
||||
function isValid12Hour(value: string) {
|
||||
return /^(0[1-9]|1[0-2])$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* regular expression to check for valid minute format (00-59)
|
||||
*/
|
||||
function isValidMinuteOrSecond(value: string) {
|
||||
return /^[0-5][0-9]$/.test(value);
|
||||
}
|
||||
|
||||
type GetValidNumberConfig = { max: number; min?: number; loop?: boolean };
|
||||
|
||||
function getValidNumber(value: string, {max, min = 0, loop = false}: GetValidNumberConfig) {
|
||||
let numericValue = parseInt(value, 10);
|
||||
|
||||
if (!Number.isNaN(numericValue)) {
|
||||
if (!loop) {
|
||||
if (numericValue > max) numericValue = max;
|
||||
if (numericValue < min) numericValue = min;
|
||||
} else {
|
||||
if (numericValue > max) numericValue = min;
|
||||
if (numericValue < min) numericValue = max;
|
||||
}
|
||||
return numericValue.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
return '00';
|
||||
}
|
||||
|
||||
function getValidHour(value: string) {
|
||||
if (isValidHour(value)) return value;
|
||||
return getValidNumber(value, {max: 23});
|
||||
}
|
||||
|
||||
function getValid12Hour(value: string) {
|
||||
if (isValid12Hour(value)) return value;
|
||||
return getValidNumber(value, {min: 1, max: 12});
|
||||
}
|
||||
|
||||
function getValidMinuteOrSecond(value: string) {
|
||||
if (isValidMinuteOrSecond(value)) return value;
|
||||
return getValidNumber(value, {max: 59});
|
||||
}
|
||||
|
||||
type GetValidArrowNumberConfig = {
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
};
|
||||
|
||||
function getValidArrowNumber(value: string, {min, max, step}: GetValidArrowNumberConfig) {
|
||||
let numericValue = parseInt(value, 10);
|
||||
if (!Number.isNaN(numericValue)) {
|
||||
numericValue += step;
|
||||
return getValidNumber(String(numericValue), {min, max, loop: true});
|
||||
}
|
||||
return '00';
|
||||
}
|
||||
|
||||
function getValidArrowHour(value: string, step: number) {
|
||||
return getValidArrowNumber(value, {min: 0, max: 23, step});
|
||||
}
|
||||
|
||||
function getValidArrow12Hour(value: string, step: number) {
|
||||
return getValidArrowNumber(value, {min: 1, max: 12, step});
|
||||
}
|
||||
|
||||
function getValidArrowMinuteOrSecond(value: string, step: number) {
|
||||
return getValidArrowNumber(value, {min: 0, max: 59, step});
|
||||
}
|
||||
|
||||
function setMinutes(date: Date, value: string) {
|
||||
const minutes = getValidMinuteOrSecond(value);
|
||||
date.setMinutes(parseInt(minutes, 10));
|
||||
return date;
|
||||
}
|
||||
|
||||
function setSeconds(date: Date, value: string) {
|
||||
const seconds = getValidMinuteOrSecond(value);
|
||||
date.setSeconds(parseInt(seconds, 10));
|
||||
return date;
|
||||
}
|
||||
|
||||
function setHours(date: Date, value: string) {
|
||||
const hours = getValidHour(value);
|
||||
date.setHours(parseInt(hours, 10));
|
||||
return date;
|
||||
}
|
||||
|
||||
function set12Hours(date: Date, value: string, period: Period) {
|
||||
const hours = parseInt(getValid12Hour(value), 10);
|
||||
const convertedHours = convert12HourTo24Hour(hours, period);
|
||||
date.setHours(convertedHours);
|
||||
return date;
|
||||
}
|
||||
|
||||
type TimePickerType = 'minutes' | 'seconds' | 'hours' | '12hours';
|
||||
type Period = 'AM' | 'PM';
|
||||
|
||||
function setDateByType(date: Date, value: string, type: TimePickerType, period?: Period) {
|
||||
switch (type) {
|
||||
case 'minutes':
|
||||
return setMinutes(date, value);
|
||||
case 'seconds':
|
||||
return setSeconds(date, value);
|
||||
case 'hours':
|
||||
return setHours(date, value);
|
||||
case '12hours': {
|
||||
if (!period) return date;
|
||||
return set12Hours(date, value, period);
|
||||
}
|
||||
default:
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
||||
function getDateByType(date: Date | null, type: TimePickerType) {
|
||||
if (!date) return '00';
|
||||
switch (type) {
|
||||
case 'minutes':
|
||||
return getValidMinuteOrSecond(String(date.getMinutes()));
|
||||
case 'seconds':
|
||||
return getValidMinuteOrSecond(String(date.getSeconds()));
|
||||
case 'hours':
|
||||
return getValidHour(String(date.getHours()));
|
||||
case '12hours':
|
||||
return getValid12Hour(String(display12HourValue(date.getHours())));
|
||||
default:
|
||||
return '00';
|
||||
}
|
||||
}
|
||||
|
||||
function getArrowByType(value: string, step: number, type: TimePickerType) {
|
||||
switch (type) {
|
||||
case 'minutes':
|
||||
return getValidArrowMinuteOrSecond(value, step);
|
||||
case 'seconds':
|
||||
return getValidArrowMinuteOrSecond(value, step);
|
||||
case 'hours':
|
||||
return getValidArrowHour(value, step);
|
||||
case '12hours':
|
||||
return getValidArrow12Hour(value, step);
|
||||
default:
|
||||
return '00';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* handles value change of 12-hour input
|
||||
* 12:00 PM is 12:00
|
||||
* 12:00 AM is 00:00
|
||||
*/
|
||||
function convert12HourTo24Hour(hour: number, period: Period) {
|
||||
if (period === 'PM') {
|
||||
if (hour <= 11) {
|
||||
return hour + 12;
|
||||
}
|
||||
return hour;
|
||||
}
|
||||
|
||||
if (period === 'AM') {
|
||||
if (hour === 12) return 0;
|
||||
return hour;
|
||||
}
|
||||
return hour;
|
||||
}
|
||||
|
||||
/**
|
||||
* time is stored in the 24-hour form,
|
||||
* but needs to be displayed to the user
|
||||
* in its 12-hour representation
|
||||
*/
|
||||
function display12HourValue(hours: number) {
|
||||
if (hours === 0 || hours === 12) return '12';
|
||||
if (hours >= 22) return `${hours - 12}`;
|
||||
if (hours % 12 > 9) return `${hours}`;
|
||||
return `0${hours % 12}`;
|
||||
}
|
||||
|
||||
function genMonths(locale: Pick<Locale, 'options' | 'localize' | 'formatLong'>) {
|
||||
return Array.from({length: 12}, (_, i) => ({
|
||||
value: i,
|
||||
label: format(new Date(2021, i), 'MMMM', {locale}),
|
||||
}));
|
||||
}
|
||||
|
||||
function genYears(yearRange = 50) {
|
||||
const today = new Date();
|
||||
return Array.from({length: yearRange * 2 + 1}, (_, i) => ({
|
||||
value: today.getFullYear() - yearRange + i,
|
||||
label: (today.getFullYear() - yearRange + i).toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------- utils end ----------
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
yearRange = 50,
|
||||
...props
|
||||
}: CalendarProps & { yearRange?: number }) {
|
||||
const MONTHS = React.useMemo(() => {
|
||||
let locale: Pick<Locale, 'options' | 'localize' | 'formatLong'> = enUS;
|
||||
const {options, localize, formatLong} = props.locale || {};
|
||||
if (options && localize && formatLong) {
|
||||
locale = {
|
||||
options,
|
||||
localize,
|
||||
formatLong,
|
||||
};
|
||||
}
|
||||
return genMonths(locale);
|
||||
}, []);
|
||||
|
||||
const YEARS = React.useMemo(() => genYears(yearRange), []);
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn('p-3', className)}
|
||||
classNames={{
|
||||
months: 'flex flex-col sm:flex-row space-y-4 sm:space-y-0 justify-center',
|
||||
month: 'flex flex-col items-center space-y-4',
|
||||
month_caption: 'flex justify-center pt-1 relative items-center',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'space-x-1 flex items-center ',
|
||||
button_previous: cn(
|
||||
buttonVariants({variant: 'outline'}),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-5 top-5',
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({variant: 'outline'}),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-5 top-5',
|
||||
),
|
||||
month_grid: 'w-full border-collapse space-y-1',
|
||||
weekdays: cn('flex', props.showWeekNumber && 'justify-end'),
|
||||
weekday: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
|
||||
week: 'flex w-full mt-2',
|
||||
day: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20 rounded-1',
|
||||
day_button: cn(
|
||||
buttonVariants({variant: 'ghost'}),
|
||||
'h-9 w-9 p-0 font-normal aria-selected:opacity-100 rounded-l-md rounded-r-md',
|
||||
),
|
||||
range_end: 'day-range-end',
|
||||
selected:
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground rounded-l-md rounded-r-md',
|
||||
today: 'bg-accent text-accent-foreground',
|
||||
outside:
|
||||
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
|
||||
disabled: 'text-muted-foreground opacity-50',
|
||||
range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
hidden: 'invisible',
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({...props}) =>
|
||||
props.orientation === 'left' ? (
|
||||
<ChevronLeft className="h-4 w-4"/>
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4"/>
|
||||
),
|
||||
MonthCaption: ({calendarMonth}) => {
|
||||
return (
|
||||
<div className="inline-flex gap-2">
|
||||
<Select
|
||||
defaultValue={calendarMonth.date.getMonth().toString()}
|
||||
onValueChange={(value) => {
|
||||
const newDate = new Date(calendarMonth.date);
|
||||
newDate.setMonth(Number.parseInt(value, 10));
|
||||
props.onMonthChange?.(newDate);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-fit gap-1 border-none p-0 focus:bg-accent focus:text-accent-foreground">
|
||||
<SelectValue/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((month) => (
|
||||
<SelectItem key={month.value} value={month.value.toString()}>
|
||||
{month.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
defaultValue={calendarMonth.date.getFullYear().toString()}
|
||||
onValueChange={(value) => {
|
||||
const newDate = new Date(calendarMonth.date);
|
||||
newDate.setFullYear(Number.parseInt(value, 10));
|
||||
props.onMonthChange?.(newDate);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-fit gap-1 border-none p-0 focus:bg-accent focus:text-accent-foreground">
|
||||
<SelectValue/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{YEARS.map((year) => (
|
||||
<SelectItem key={year.value} value={year.value.toString()}>
|
||||
{year.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Calendar.displayName = 'Calendar';
|
||||
|
||||
interface PeriodSelectorProps {
|
||||
period: Period;
|
||||
setPeriod?: (m: Period) => void;
|
||||
date?: Date | null;
|
||||
onDateChange?: (date: Date | undefined) => void;
|
||||
onRightFocus?: () => void;
|
||||
onLeftFocus?: () => void;
|
||||
}
|
||||
|
||||
const TimePeriodSelect = React.forwardRef<HTMLButtonElement, PeriodSelectorProps>(
|
||||
({period, setPeriod, date, onDateChange, onLeftFocus, onRightFocus}, ref) => {
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === 'ArrowRight') onRightFocus?.();
|
||||
if (e.key === 'ArrowLeft') onLeftFocus?.();
|
||||
};
|
||||
|
||||
const handleValueChange = (value: Period) => {
|
||||
setPeriod?.(value);
|
||||
|
||||
/**
|
||||
* trigger an update whenever the user switches between AM and PM;
|
||||
* otherwise user must manually change the hour each time
|
||||
*/
|
||||
if (date) {
|
||||
const tempDate = new Date(date);
|
||||
const hours = display12HourValue(date.getHours());
|
||||
onDateChange?.(
|
||||
setDateByType(tempDate, hours.toString(), '12hours', period === 'AM' ? 'PM' : 'AM'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-10 items-center">
|
||||
<Select defaultValue={period} onValueChange={(value: Period) => handleValueChange(value)}>
|
||||
<SelectTrigger
|
||||
ref={ref}
|
||||
className="w-[65px] focus:bg-accent focus:text-accent-foreground"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<SelectValue/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AM">AM</SelectItem>
|
||||
<SelectItem value="PM">PM</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TimePeriodSelect.displayName = 'TimePeriodSelect';
|
||||
|
||||
interface TimePickerInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
picker: TimePickerType;
|
||||
date?: Date | null;
|
||||
onDateChange?: (date: Date | undefined) => void;
|
||||
period?: Period;
|
||||
onRightFocus?: () => void;
|
||||
onLeftFocus?: () => void;
|
||||
}
|
||||
|
||||
const TimePickerInput = React.forwardRef<HTMLInputElement, TimePickerInputProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
type = 'tel',
|
||||
value,
|
||||
id,
|
||||
name,
|
||||
date = new Date(new Date().setHours(0, 0, 0, 0)),
|
||||
onDateChange,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
picker,
|
||||
period,
|
||||
onLeftFocus,
|
||||
onRightFocus,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [flag, setFlag] = React.useState<boolean>(false);
|
||||
const [prevIntKey, setPrevIntKey] = React.useState<string>('0');
|
||||
|
||||
/**
|
||||
* allow the user to enter the second digit within 2 seconds
|
||||
* otherwise start again with entering first digit
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (flag) {
|
||||
const timer = setTimeout(() => {
|
||||
setFlag(false);
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [flag]);
|
||||
|
||||
const calculatedValue = React.useMemo(() => {
|
||||
return getDateByType(date, picker);
|
||||
}, [date, picker]);
|
||||
|
||||
const calculateNewValue = (key: string) => {
|
||||
/*
|
||||
* If picker is '12hours' and the first digit is 0, then the second digit is automatically set to 1.
|
||||
* The second entered digit will break the condition and the value will be set to 10-12.
|
||||
*/
|
||||
if (picker === '12hours') {
|
||||
if (flag && calculatedValue.slice(1, 2) === '1' && prevIntKey === '0') return `0${key}`;
|
||||
}
|
||||
|
||||
return !flag ? `0${key}` : calculatedValue.slice(1, 2) + key;
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Tab') return;
|
||||
e.preventDefault();
|
||||
if (e.key === 'ArrowRight') onRightFocus?.();
|
||||
if (e.key === 'ArrowLeft') onLeftFocus?.();
|
||||
if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||
const step = e.key === 'ArrowUp' ? 1 : -1;
|
||||
const newValue = getArrowByType(calculatedValue, step, picker);
|
||||
if (flag) setFlag(false);
|
||||
const tempDate = date ? new Date(date) : new Date();
|
||||
onDateChange?.(setDateByType(tempDate, newValue, picker, period));
|
||||
}
|
||||
if (e.key >= '0' && e.key <= '9') {
|
||||
if (picker === '12hours') setPrevIntKey(e.key);
|
||||
|
||||
const newValue = calculateNewValue(e.key);
|
||||
if (flag) onRightFocus?.();
|
||||
setFlag((prev) => !prev);
|
||||
const tempDate = date ? new Date(date) : new Date();
|
||||
onDateChange?.(setDateByType(tempDate, newValue, picker, period));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
id={id || picker}
|
||||
name={name || picker}
|
||||
className={cn(
|
||||
'w-[48px] text-center font-mono text-base tabular-nums caret-transparent focus:bg-accent focus:text-accent-foreground [&::-webkit-inner-spin-button]:appearance-none',
|
||||
className,
|
||||
)}
|
||||
value={value || calculatedValue}
|
||||
onChange={(e) => {
|
||||
e.preventDefault();
|
||||
onChange?.(e);
|
||||
}}
|
||||
type={type}
|
||||
inputMode="decimal"
|
||||
onKeyDown={(e) => {
|
||||
onKeyDown?.(e);
|
||||
handleKeyDown(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TimePickerInput.displayName = 'TimePickerInput';
|
||||
|
||||
interface TimePickerProps {
|
||||
date?: Date | null;
|
||||
onChange?: (date: Date | undefined) => void;
|
||||
hourCycle?: 12 | 24;
|
||||
/**
|
||||
* Determines the smallest unit that is displayed in the datetime picker.
|
||||
* Default is 'second'.
|
||||
* */
|
||||
granularity?: Granularity;
|
||||
}
|
||||
|
||||
interface TimePickerRef {
|
||||
minuteRef: HTMLInputElement | null;
|
||||
hourRef: HTMLInputElement | null;
|
||||
secondRef: HTMLInputElement | null;
|
||||
}
|
||||
|
||||
const TimePicker = React.forwardRef<TimePickerRef, TimePickerProps>(
|
||||
({date, onChange, hourCycle = 24, granularity = 'second'}, ref) => {
|
||||
const minuteRef = React.useRef<HTMLInputElement>(null);
|
||||
const hourRef = React.useRef<HTMLInputElement>(null);
|
||||
const secondRef = React.useRef<HTMLInputElement>(null);
|
||||
const periodRef = React.useRef<HTMLButtonElement>(null);
|
||||
const [period, setPeriod] = React.useState<Period>(date && date.getHours() >= 12 ? 'PM' : 'AM');
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
minuteRef: minuteRef.current,
|
||||
hourRef: hourRef.current,
|
||||
secondRef: secondRef.current,
|
||||
periodRef: periodRef.current,
|
||||
}),
|
||||
[minuteRef, hourRef, secondRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<label htmlFor="datetime-picker-hour-input" className="cursor-pointer">
|
||||
<Clock className="mr-2 h-4 w-4"/>
|
||||
</label>
|
||||
<TimePickerInput
|
||||
picker={hourCycle === 24 ? 'hours' : '12hours'}
|
||||
date={date}
|
||||
id="datetime-picker-hour-input"
|
||||
onDateChange={onChange}
|
||||
ref={hourRef}
|
||||
period={period}
|
||||
onRightFocus={() => minuteRef?.current?.focus()}
|
||||
/>
|
||||
{(granularity === 'minute' || granularity === 'second') && (
|
||||
<>
|
||||
:
|
||||
<TimePickerInput
|
||||
picker="minutes"
|
||||
date={date}
|
||||
onDateChange={onChange}
|
||||
ref={minuteRef}
|
||||
onLeftFocus={() => hourRef?.current?.focus()}
|
||||
onRightFocus={() => secondRef?.current?.focus()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{granularity === 'second' && (
|
||||
<>
|
||||
:
|
||||
<TimePickerInput
|
||||
picker="seconds"
|
||||
date={date}
|
||||
onDateChange={onChange}
|
||||
ref={secondRef}
|
||||
onLeftFocus={() => minuteRef?.current?.focus()}
|
||||
onRightFocus={() => periodRef?.current?.focus()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{hourCycle === 12 && (
|
||||
<div className="grid gap-1 text-center">
|
||||
<TimePeriodSelect
|
||||
period={period}
|
||||
setPeriod={setPeriod}
|
||||
date={date}
|
||||
onDateChange={(date) => {
|
||||
onChange?.(date);
|
||||
if (date && date?.getHours() >= 12) {
|
||||
setPeriod('PM');
|
||||
} else {
|
||||
setPeriod('AM');
|
||||
}
|
||||
}}
|
||||
ref={periodRef}
|
||||
onLeftFocus={() => secondRef?.current?.focus()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
TimePicker.displayName = 'TimePicker';
|
||||
|
||||
type Granularity = 'day' | 'hour' | 'minute' | 'second';
|
||||
|
||||
type DateTimePickerProps = {
|
||||
value?: Date;
|
||||
onChange?: (date: Date | undefined) => void;
|
||||
disabled?: boolean;
|
||||
/** showing `AM/PM` or not. */
|
||||
hourCycle?: 12 | 24;
|
||||
placeholder?: string;
|
||||
/**
|
||||
* The year range will be: `This year + yearRange` and `this year - yearRange`.
|
||||
* Default is 50.
|
||||
* For example:
|
||||
* This year is 2024, The year dropdown will be 1974 to 2024 which is generated by `2024 - 50 = 1974` and `2024 + 50 = 2074`.
|
||||
* */
|
||||
yearRange?: number;
|
||||
/**
|
||||
* The format is derived from the `date-fns` documentation.
|
||||
* @reference https://date-fns.org/v3.6.0/docs/format
|
||||
**/
|
||||
displayFormat?: { hour24?: string; hour12?: string };
|
||||
/**
|
||||
* The granularity prop allows you to control the smallest unit that is displayed by DateTimePicker.
|
||||
* By default, the value is `second` which shows all time inputs.
|
||||
**/
|
||||
granularity?: Granularity;
|
||||
className?: string;
|
||||
} & Pick<CalendarProps, 'locale' | 'weekStartsOn' | 'showWeekNumber' | 'showOutsideDays'>;
|
||||
|
||||
type DateTimePickerRef = {
|
||||
value?: Date;
|
||||
} & Omit<HTMLButtonElement, 'value'>;
|
||||
|
||||
const DateTimePicker = React.forwardRef<Partial<DateTimePickerRef>, DateTimePickerProps>(
|
||||
(
|
||||
{
|
||||
locale = enUS,
|
||||
value,
|
||||
onChange,
|
||||
hourCycle = 24,
|
||||
yearRange = 50,
|
||||
disabled = false,
|
||||
displayFormat,
|
||||
granularity = 'second',
|
||||
placeholder = 'Pick a date',
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [month, setMonth] = React.useState<Date>(value ?? new Date());
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
/**
|
||||
* carry over the current time when a user clicks a new day
|
||||
* instead of resetting to 00:00
|
||||
*/
|
||||
const handleSelect = (newDay: Date | undefined) => {
|
||||
if (!newDay) return;
|
||||
if (!value) {
|
||||
onChange?.(newDay);
|
||||
setMonth(newDay);
|
||||
return;
|
||||
}
|
||||
const diff = newDay.getTime() - value.getTime();
|
||||
const diffInDays = diff / (1000 * 60 * 60 * 24);
|
||||
const newDateFull = add(value, {days: Math.ceil(diffInDays)});
|
||||
onChange?.(newDateFull);
|
||||
setMonth(newDateFull);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
...buttonRef.current,
|
||||
value,
|
||||
}),
|
||||
[value],
|
||||
);
|
||||
|
||||
const initHourFormat = {
|
||||
hour24:
|
||||
displayFormat?.hour24 ??
|
||||
`PPP HH:mm${!granularity || granularity === 'second' ? ':ss' : ''}`,
|
||||
hour12:
|
||||
displayFormat?.hour12 ??
|
||||
`PP hh:mm${!granularity || granularity === 'second' ? ':ss' : ''} b`,
|
||||
};
|
||||
|
||||
let loc = enUS;
|
||||
const {options, localize, formatLong} = locale;
|
||||
if (options && localize && formatLong) {
|
||||
loc = {
|
||||
...enUS,
|
||||
options,
|
||||
localize,
|
||||
formatLong,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild disabled={disabled}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
!value && 'text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
ref={buttonRef}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4"/>
|
||||
{value ? (
|
||||
format(value, hourCycle === 24 ? initHourFormat.hour24 : initHourFormat.hour12, {
|
||||
locale: loc,
|
||||
})
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
month={month}
|
||||
onSelect={(d) => handleSelect(d)}
|
||||
onMonthChange={handleSelect}
|
||||
yearRange={yearRange}
|
||||
locale={locale}
|
||||
{...props}
|
||||
/>
|
||||
{granularity !== 'day' && (
|
||||
<div className="border-t border-border p-3">
|
||||
<TimePicker
|
||||
onChange={onChange}
|
||||
date={value}
|
||||
hourCycle={hourCycle}
|
||||
granularity={granularity}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DateTimePicker.displayName = 'DateTimePicker';
|
||||
|
||||
export {DateTimePicker, TimePickerInput, TimePicker};
|
||||
export type {TimePickerType, DateTimePickerProps, DateTimePickerRef};
|
||||
+4
-3
@@ -4,7 +4,8 @@ export type LayoutParams<T extends Record<string, string | string[]>> = {
|
||||
|
||||
};
|
||||
|
||||
|
||||
export type PageParams<T extends Record<string, string | string[]>> = {
|
||||
params: T;
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
params: Promise<T>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
};
|
||||
@@ -1878,10 +1878,10 @@ data-view-byte-offset@^1.0.0:
|
||||
es-errors "^1.3.0"
|
||||
is-data-view "^1.0.1"
|
||||
|
||||
date-fns@^3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf"
|
||||
integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==
|
||||
date-fns@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14"
|
||||
integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==
|
||||
|
||||
debug@4, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5:
|
||||
version "4.3.7"
|
||||
@@ -3640,7 +3640,7 @@ queue-microtask@^1.2.2:
|
||||
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
|
||||
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
|
||||
|
||||
react-day-picker@^8.10.1:
|
||||
react-day-picker@8.10.1:
|
||||
version "8.10.1"
|
||||
resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-8.10.1.tgz#4762ec298865919b93ec09ba69621580835b8e80"
|
||||
integrity sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==
|
||||
|
||||
Reference in New Issue
Block a user