From 143c21305a541b9aa06b5050a58499174c930d2f Mon Sep 17 00:00:00 2001 From: Maaz Khokhar Date: Sat, 27 Jun 2026 19:54:57 -0500 Subject: [PATCH] removed db from gitignore + made it so that the app cards render dynamically + delete dialog + add actually works + no crashing --- .env.example | 1 - .gitignore | 4 +- .vscode/settings.json | 3 + app/dashboard/page.tsx | 11 ++- app/hold-confirm.css | 63 ++++++++++++ app/layout.tsx | 13 ++- bun.lock | 5 + components/action-menu.tsx | 155 +++++++++++++++++------------ components/app-grid.tsx | 51 ++++++++++ components/confirm-button.tsx | 162 +++++++++++++++++++++++++++++++ components/delete-dialog.tsx | 62 ++++++++++++ components/plus-button.tsx | 99 +++++++++++-------- components/query-provider.tsx | 15 +++ components/suspence/app-grid.tsx | 12 +++ components/ui/spinner.tsx | 16 +++ devenv.nix | 2 +- package.json | 1 + src-tauri/aperture.db | Bin 40960 -> 0 bytes src-tauri/src/db/mod.rs | 33 +++++++ src-tauri/src/db/models.rs | 7 +- src-tauri/src/lib.rs | 32 +++--- 21 files changed, 616 insertions(+), 131 deletions(-) delete mode 100644 .env.example create mode 100644 .vscode/settings.json create mode 100644 app/hold-confirm.css create mode 100644 components/app-grid.tsx create mode 100644 components/confirm-button.tsx create mode 100644 components/delete-dialog.tsx create mode 100644 components/query-provider.tsx create mode 100644 components/suspence/app-grid.tsx create mode 100644 components/ui/spinner.tsx delete mode 100644 src-tauri/aperture.db diff --git a/.env.example b/.env.example deleted file mode 100644 index 8f66ce7..0000000 --- a/.env.example +++ /dev/null @@ -1 +0,0 @@ -DATABASE_URL=./aperture.db diff --git a/.gitignore b/.gitignore index 3ace764..90acbcc 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ node_modules/ .next/ .env* -!.env.example \ No newline at end of file +!.env.example + +aperture.db \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2606af3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cSpell.words": ["tauri"] +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 011d208..53fd4f3 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,5 +1,7 @@ -import AppCard from "@/components/app-card"; +import AppGrid from "@/components/app-grid"; import PlusButton from "@/components/plus-button"; +import AppGridFallback from "@/components/suspence/app-grid"; +import { Suspense } from "react"; export default function Dashboard() { const mockService = { @@ -18,10 +20,9 @@ export default function Dashboard() { return (
-
- - -
+ }> + +
); diff --git a/app/hold-confirm.css b/app/hold-confirm.css new file mode 100644 index 0000000..d7b3308 --- /dev/null +++ b/app/hold-confirm.css @@ -0,0 +1,63 @@ +:root { + font-size: 16px; /* => 1rem */ +} + +.PressToConfirm { + --bg-color: #dd3b38; + --fill-color: #ffffff33; + --bg-color-hover: #d42e2c; + --border-color: #c33532; + --shadow-color: #580c0ca6; + --text-color: white; + + box-sizing: border-box; + white-space: nowrap; + font-weight: 500; + font-family: inherit; + font-size: 0.875rem; + text-align: center; + cursor: pointer; + transition: 0.1s ease-in-out; + background-color: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 5rem; + color: var(--text-color); + line-height: 1.25rem; + padding: 0.35rem 1rem; + box-shadow: 0 1px 2px 0 var(--shadow-color); + position: relative; + overflow: hidden; + min-width: 75px; + + user-select: none; + -webkit-user-select: none; + touch-action: none; +} + +.PressToConfirm:hover:not(.active) { + background-color: var(--bg-color-hover); +} + +.PressToConfirm:focus { + outline-offset: 6px; +} + +.PressToConfirm:focus-visible { + box-shadow: none; +} + +.PressToConfirm .filler { + background-color: var(--fill-color); + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 100%; + pointer-events: none; +} + +.text { + user-select: none; + -webkit-user-select: none; + pointer-events: none; +} diff --git a/app/layout.tsx b/app/layout.tsx index 455f102..556d8bb 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,8 @@ import { Inter } from "next/font/google"; import "./globals.css"; import { cn } from "@/lib/utils"; import Sidebar from "@/components/sidebar"; +import { Toaster } from "@/components/ui/sonner"; +import QueryProvider from "@/components/query-provider"; const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); @@ -12,8 +14,15 @@ export const metadata: Metadata = { description: "A homelab managment and blogging platform", }; -function Providers({ children }: { children: React.ReactNode }) { - return {children}; +async function Providers({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + ); } export default function RootLayout({ diff --git a/bun.lock b/bun.lock index 609845a..5c1ac48 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "aperture", "dependencies": { + "@tanstack/react-query": "^5.101.2", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "class-variance-authority": "^0.7.1", @@ -372,6 +373,10 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "postcss": "8.5.15", "tailwindcss": "4.3.1" } }, "sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A=="], + "@tanstack/query-core": ["@tanstack/query-core@5.101.2", "", {}, "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.101.2", "", { "dependencies": { "@tanstack/query-core": "5.101.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg=="], + "@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], "@tauri-apps/cli": ["@tauri-apps/cli@2.11.3", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.3", "@tauri-apps/cli-darwin-x64": "2.11.3", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.3", "@tauri-apps/cli-linux-arm64-gnu": "2.11.3", "@tauri-apps/cli-linux-arm64-musl": "2.11.3", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.3", "@tauri-apps/cli-linux-x64-gnu": "2.11.3", "@tauri-apps/cli-linux-x64-musl": "2.11.3", "@tauri-apps/cli-win32-arm64-msvc": "2.11.3", "@tauri-apps/cli-win32-ia32-msvc": "2.11.3", "@tauri-apps/cli-win32-x64-msvc": "2.11.3" }, "bin": { "tauri": "tauri.js" } }, "sha512-EElQe8z8uD7Pi5++tJ/UfEwWuK08rd3oCDYdeIbJAb6pZRrxlqmoF5gh5H5YvzmUPhS4IRCaLSsQhvWkrfK+GQ=="], diff --git a/components/action-menu.tsx b/components/action-menu.tsx index 46ba12e..3c9935b 100644 --- a/components/action-menu.tsx +++ b/components/action-menu.tsx @@ -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 ( - - + + +
+ + + + + +

Delete

+
+
+ + + + + + +

SSH

+
+
+ + + + + + +

Create Blog

+
+
+ + + + + + +

Run Scripts

+
+
+
+
+
+ + { + console.log("DeleteDialog open state changed:", open); + setOpen(open); }} - className="flex justify-end" - > -
- - - - - -

SSH

-
-
- - - - - - -

Create Blog

-
-
- - - - - - -

Run Scripts

-
-
-
-
-
+ id={id} + /> + ); } diff --git a/components/app-grid.tsx b/components/app-grid.tsx new file mode 100644 index 0000000..e5debf8 --- /dev/null +++ b/components/app-grid.tsx @@ -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 ( +
+ {isSuccess && + apps && + apps.map((app) => )} + {isSuccess && apps && apps.length === 0 && ( +
+ No apps found. Click the "+" button to add a new app. +
+ )} +
+ ); +} diff --git a/components/confirm-button.tsx b/components/confirm-button.tsx new file mode 100644 index 0000000..8189c93 --- /dev/null +++ b/components/confirm-button.tsx @@ -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(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 ( + { + // 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"} + > + + + + {text} + + + + ); +}; diff --git a/components/delete-dialog.tsx b/components/delete-dialog.tsx new file mode 100644 index 0000000..5c819e7 --- /dev/null +++ b/components/delete-dialog.tsx @@ -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(null); + + const handleDelete = async (e: React.FormEvent) => { + e.preventDefault(); + console.log(`Deleting app with id: ${id}`); + onOpenChange(false); // Close dialog after deleting + }; + + const handleHoldComplete = () => { + if (formRef.current) { + formRef.current.requestSubmit(); + } + }; + + return ( + +
+ + + Delete App + + Are you sure you want to delete this app? This action cannot be + undone. + + + + + + + + + +
+
+ ); +} diff --git a/components/plus-button.tsx b/components/plus-button.tsx index 196952a..b4ac3fc 100644 --- a/components/plus-button.tsx +++ b/components/plus-button.tsx @@ -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(""); - const [sshAddr, setSSHAddr] = useState(""); + const [sshAddr, setSSHAddr] = useState("192.168.1.10"); - const onSubmit = async () => { - let tmp_description = undefined; - let tmp_ssh = undefined; + const handleSubmit = async (e: SubmitEvent) => { + 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 ( - -
onSubmit()}> - - - - - - + setOpen(e.valueOf())} open={open}> + + + + + + + Create Service @@ -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)} /> @@ -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)} /> @@ -94,7 +114,7 @@ export default function PlusButton() {