removed db from gitignore + made it so that the app cards render dynamically + delete dialog + add actually works + no crashing
This commit is contained in:
+92
-63
@@ -1,4 +1,6 @@
|
||||
import { Terminal, Pencil, FileTerminal } from "lucide-react";
|
||||
"use client";
|
||||
|
||||
import { Terminal, Pencil, FileTerminal, Trash } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import {
|
||||
@@ -7,6 +9,8 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import DeleteDialog from "./delete-dialog";
|
||||
import { useState } from "react";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
@@ -15,69 +19,94 @@ type Props = {
|
||||
export default function ActionMenu({ id }: Props) {
|
||||
const buttonClass =
|
||||
"flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-all duration-150 hover:bg-green-50 hover:text-green-600 active:scale-95";
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.95 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 450,
|
||||
damping: 28,
|
||||
<>
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.95 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 450,
|
||||
damping: 28,
|
||||
}}
|
||||
className="flex justify-end"
|
||||
>
|
||||
<div className="flex flex-row justify-end gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button onClick={() => setOpen(true)} className={buttonClass}>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={buttonClass}
|
||||
onClick={() => {
|
||||
// SSH
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>SSH</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={buttonClass}
|
||||
onClick={() => {
|
||||
// Blog
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Create Blog</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={buttonClass}
|
||||
onClick={() => {
|
||||
// Scripts
|
||||
}}
|
||||
>
|
||||
<FileTerminal className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Run Scripts</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</motion.div>
|
||||
</TooltipProvider>
|
||||
|
||||
<DeleteDialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
console.log("DeleteDialog open state changed:", open);
|
||||
setOpen(open);
|
||||
}}
|
||||
className="flex justify-end"
|
||||
>
|
||||
<div className="flex flex-row justify-end gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={buttonClass}
|
||||
onClick={() => {
|
||||
// SSH
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>SSH</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={buttonClass}
|
||||
onClick={() => {
|
||||
// Blog
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Create Blog</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={buttonClass}
|
||||
onClick={() => {
|
||||
// Scripts
|
||||
}}
|
||||
>
|
||||
<FileTerminal className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Run Scripts</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</motion.div>
|
||||
</TooltipProvider>
|
||||
id={id}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { toast } from "sonner";
|
||||
import AppCard from "./app-card";
|
||||
|
||||
type App = {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
description?: string;
|
||||
ssh_address?: string;
|
||||
};
|
||||
|
||||
export default function AppGrid() {
|
||||
const getApps = async () => {
|
||||
const apps: App[] = await invoke("get_app");
|
||||
return apps;
|
||||
};
|
||||
|
||||
const {
|
||||
data: apps,
|
||||
isSuccess,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["apps"],
|
||||
queryFn: getApps,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
toast.error(`Error fetching apps: ${error}`);
|
||||
}
|
||||
}, [isError, error]);
|
||||
|
||||
return (
|
||||
<div className="grid xs:grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 m-4">
|
||||
{isSuccess &&
|
||||
apps &&
|
||||
apps.map((app) => <AppCard app={app} key={app.id} />)}
|
||||
{isSuccess && apps && apps.length === 0 && (
|
||||
<div className="col-span-full text-center text-gray-500">
|
||||
No apps found. Click the "+" button to add a new app.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, PointerEvent } from "react";
|
||||
import {
|
||||
AnimatePresence,
|
||||
Variants,
|
||||
animate,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useTransform,
|
||||
} from "framer-motion";
|
||||
import "@/app/hold-confirm.css";
|
||||
|
||||
type Direction = "back" | "forward";
|
||||
|
||||
const textVariants: Variants = {
|
||||
initial: (direction: Direction) => ({
|
||||
y: direction === "forward" ? "-30%" : "30%",
|
||||
opacity: 0,
|
||||
}),
|
||||
target: {
|
||||
y: "0%",
|
||||
opacity: 1,
|
||||
},
|
||||
exit: (direction: Direction) => ({
|
||||
y: direction === "forward" ? "30%" : "-30%",
|
||||
opacity: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
const buttonVariants: Variants = {
|
||||
idle: {
|
||||
x: 0,
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
type HoldToConfirmProps = {
|
||||
text: string;
|
||||
confirmTimeout?: number;
|
||||
onConfirm?: () => void;
|
||||
};
|
||||
|
||||
export const ConfirmButton = ({
|
||||
text: textFromProps,
|
||||
confirmTimeout = 2,
|
||||
onConfirm,
|
||||
}: HoldToConfirmProps) => {
|
||||
const startCountdown = () => {
|
||||
setState("inProgress");
|
||||
const pattern = new Array(confirmTimeout * 10)
|
||||
.fill(0)
|
||||
.map((_, ind) => (ind % 2 === 0 ? 100 : 50));
|
||||
animate(progress, 1, { duration: confirmTimeout, ease: "linear" }).then(
|
||||
() => {
|
||||
if (progress.get() !== 1) return;
|
||||
setState("complete");
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const cancelCountdown = () => {
|
||||
progress.stop();
|
||||
setState("idle");
|
||||
animate(progress, 0, { duration: 0.2, ease: "linear" });
|
||||
};
|
||||
|
||||
const pointerUp = (e: PointerEvent) => {
|
||||
const target = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (progress.get() === 1 && ref.current?.contains(target)) {
|
||||
animate(fillerConfirmAnimationProgress, 1, {
|
||||
duration: 0.2,
|
||||
ease: "linear",
|
||||
}).then(() => {
|
||||
fillerConfirmAnimationProgress.jump(0);
|
||||
progress.jump(0);
|
||||
setState("idle");
|
||||
onConfirm?.();
|
||||
});
|
||||
} else {
|
||||
cancelCountdown();
|
||||
}
|
||||
};
|
||||
|
||||
const pointerMove = (e: PointerEvent) => {
|
||||
// Mouse will be handled by onPointerLeave
|
||||
if (e.pointerType === "mouse") return;
|
||||
const target = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!ref.current?.contains(target)) {
|
||||
cancelCountdown();
|
||||
}
|
||||
};
|
||||
|
||||
const [state, setState] = useState<"idle" | "inProgress" | "complete">(
|
||||
"idle",
|
||||
);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const progress = useMotionValue(0);
|
||||
const fillRightOffset = useTransform(progress, (v) => `${(1 - v) * 100}%`);
|
||||
|
||||
// This is used in 'completion' animation
|
||||
const fillerConfirmAnimationProgress = useMotionValue(0);
|
||||
const fillLeftOffset = useTransform(
|
||||
fillerConfirmAnimationProgress,
|
||||
(v) => `${v * 100}%`,
|
||||
);
|
||||
|
||||
const text =
|
||||
state === "idle"
|
||||
? textFromProps
|
||||
: state === "inProgress"
|
||||
? "Hold to confirm"
|
||||
: "Release to confirm";
|
||||
|
||||
const textDirection: Direction = state === "idle" ? "back" : "forward";
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
className="PressToConfirm"
|
||||
ref={ref}
|
||||
onPointerDown={startCountdown}
|
||||
onPointerUp={pointerUp}
|
||||
onPointerCancel={cancelCountdown}
|
||||
onPointerLeave={(e) => {
|
||||
// For touchscreen browser always generates PointerLeave at
|
||||
// the end of touch, even if it ended on the element, so
|
||||
// we handle only mouse leave here
|
||||
if (e.pointerType === "mouse") cancelCountdown();
|
||||
}}
|
||||
onPointerMove={pointerMove}
|
||||
// Prevent context menu on mobiles (caused by long touch)
|
||||
onContextMenuCapture={(e) => e.preventDefault()}
|
||||
variants={buttonVariants}
|
||||
animate={state === "inProgress" ? "shaking" : "idle"}
|
||||
>
|
||||
<motion.div
|
||||
className="filler"
|
||||
style={{
|
||||
left: fillLeftOffset,
|
||||
right: fillRightOffset,
|
||||
}}
|
||||
/>
|
||||
<AnimatePresence custom={textDirection} initial={false} mode="popLayout">
|
||||
<motion.div
|
||||
key={text}
|
||||
className="text"
|
||||
variants={textVariants}
|
||||
custom={textDirection}
|
||||
initial="initial"
|
||||
animate="target"
|
||||
exit="exit"
|
||||
>
|
||||
{text}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "./confirm-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type DeleteDialogProps = {
|
||||
id: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export default function DeleteDialog({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DeleteDialogProps) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const handleDelete = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
console.log(`Deleting app with id: ${id}`);
|
||||
onOpenChange(false); // Close dialog after deleting
|
||||
};
|
||||
|
||||
const handleHoldComplete = () => {
|
||||
if (formRef.current) {
|
||||
formRef.current.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<form ref={formRef} onSubmit={handleDelete}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete App</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this app? This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<ConfirmButton text="Confirm" onConfirm={handleHoldComplete} />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+60
-39
@@ -19,46 +19,67 @@ import { Label } from "./ui/label";
|
||||
import { Input } from "./ui/input";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
import { useState } from "react";
|
||||
import { useState, SubmitEvent } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export default function PlusButton() {
|
||||
const [differentAddr, setDifferentAddr] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const queryclient = useQueryClient();
|
||||
|
||||
// 1. Properly set default values inside the initial state
|
||||
const [name, setName] = useState("Kubernetes");
|
||||
const [address, setAddress] = useState("192.168.1.10");
|
||||
const [description, setDescription] = useState<string>("");
|
||||
const [sshAddr, setSSHAddr] = useState<string>("");
|
||||
const [sshAddr, setSSHAddr] = useState<string>("192.168.1.10");
|
||||
|
||||
const onSubmit = async () => {
|
||||
let tmp_description = undefined;
|
||||
let tmp_ssh = undefined;
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
console.log("Form submission triggered");
|
||||
|
||||
if (!name.trim() || !address.trim()) return;
|
||||
|
||||
let tmp_description: string | undefined = undefined;
|
||||
let tmp_ssh: string | undefined = undefined;
|
||||
|
||||
if (!name.trim()) return;
|
||||
if (!address.trim()) return;
|
||||
if (description.trim()) tmp_description = description.trim();
|
||||
if (sshAddr.trim()) tmp_ssh = sshAddr.trim();
|
||||
if (differentAddr && sshAddr.trim()) tmp_ssh = sshAddr.trim();
|
||||
|
||||
await invoke("new_app", {
|
||||
name: name,
|
||||
address: address,
|
||||
description: description,
|
||||
sshAddress: sshAddr,
|
||||
});
|
||||
try {
|
||||
let app = await invoke("new_app", {
|
||||
name: name.trim(),
|
||||
address: address.trim(),
|
||||
description: tmp_description,
|
||||
sshAddress: tmp_ssh,
|
||||
});
|
||||
|
||||
console.log("Tauri response:", app);
|
||||
|
||||
setOpen(false);
|
||||
|
||||
queryclient.invalidateQueries({
|
||||
queryKey: ["apps"],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to invoke new_app:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<form onSubmit={() => onSubmit()}>
|
||||
<DialogTrigger asChild>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||
className="absolute right-3 bottom-3 bg-emerald-700 p-2 rounded-full"
|
||||
>
|
||||
<Plus className="w-8 h-8 text-white" />
|
||||
</motion.button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<Dialog onOpenChange={(e) => setOpen(e.valueOf())} open={open}>
|
||||
<DialogTrigger asChild>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||
className="absolute right-3 bottom-3 bg-emerald-700 p-2 rounded-full"
|
||||
>
|
||||
<Plus className="w-8 h-8 text-white" />
|
||||
</motion.button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Service</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -72,8 +93,8 @@ export default function PlusButton() {
|
||||
id="name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.trim())}
|
||||
defaultValue="Kubernetes"
|
||||
// Removed trimming on change to allow spaces while typing
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
@@ -82,8 +103,7 @@ export default function PlusButton() {
|
||||
id="address"
|
||||
name="address"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value.trim())}
|
||||
defaultValue="192.168.1.10"
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
@@ -94,7 +114,7 @@ export default function PlusButton() {
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value.trim())}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
name="description"
|
||||
/>
|
||||
</Field>
|
||||
@@ -126,8 +146,7 @@ export default function PlusButton() {
|
||||
id="ssh-addr"
|
||||
name="ssh-addr"
|
||||
value={sshAddr}
|
||||
onChange={(e) => setSSHAddr(e.target.value.trim())}
|
||||
defaultValue="192.168.1.10"
|
||||
onChange={(e) => setSSHAddr(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</motion.div>
|
||||
@@ -136,12 +155,14 @@ export default function PlusButton() {
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</form>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export default function QueryProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Spinner } from "../ui/spinner";
|
||||
|
||||
export default function AppGridFallback() {
|
||||
const commonClasses = "text-black/50 shimmer shimmer-color-emerald-700";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Spinner className={`${commonClasses}`} />
|
||||
<p className={`${commonClasses} text-center`}>Loading apps...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
data-slot="spinner"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Spinner };
|
||||
Reference in New Issue
Block a user