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:
2026-06-27 19:54:57 -05:00
parent c9a7a4bdca
commit 143c21305a
21 changed files with 616 additions and 131 deletions
+92 -63
View File
@@ -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}
/>
</>
);
}
+51
View File
@@ -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>
);
}
+162
View File
@@ -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>
);
};
+62
View File
@@ -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
View File
@@ -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>
);
}
+15
View File
@@ -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>
);
}
+12
View File
@@ -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>
);
}
+16
View File
@@ -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 };