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
-1
View File
@@ -1 +0,0 @@
DATABASE_URL=./aperture.db
+3 -1
View File
@@ -14,4 +14,6 @@ node_modules/
.next/ .next/
.env* .env*
!.env.example !.env.example
aperture.db
+3
View File
@@ -0,0 +1,3 @@
{
"cSpell.words": ["tauri"]
}
+6 -5
View File
@@ -1,5 +1,7 @@
import AppCard from "@/components/app-card"; import AppGrid from "@/components/app-grid";
import PlusButton from "@/components/plus-button"; import PlusButton from "@/components/plus-button";
import AppGridFallback from "@/components/suspence/app-grid";
import { Suspense } from "react";
export default function Dashboard() { export default function Dashboard() {
const mockService = { const mockService = {
@@ -18,10 +20,9 @@ export default function Dashboard() {
return ( return (
<main> <main>
<PlusButton /> <PlusButton />
<div className="grid xs:grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 m-4"> <Suspense fallback={<AppGridFallback />}>
<AppCard app={mockService} /> <AppGrid />
<AppCard app={mockServiceDesc} /> </Suspense>
</div>
<hr /> <hr />
</main> </main>
); );
+63
View File
@@ -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;
}
+11 -2
View File
@@ -4,6 +4,8 @@ import { Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Sidebar from "@/components/sidebar"; 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" }); const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
@@ -12,8 +14,15 @@ export const metadata: Metadata = {
description: "A homelab managment and blogging platform", description: "A homelab managment and blogging platform",
}; };
function Providers({ children }: { children: React.ReactNode }) { async function Providers({ children }: { children: React.ReactNode }) {
return <TooltipProvider>{children}</TooltipProvider>; return (
<QueryProvider>
<TooltipProvider>
<Toaster />
{children}
</TooltipProvider>
</QueryProvider>
);
} }
export default function RootLayout({ export default function RootLayout({
+5
View File
@@ -5,6 +5,7 @@
"": { "": {
"name": "aperture", "name": "aperture",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.101.2",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"class-variance-authority": "^0.7.1", "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=="], "@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/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=="], "@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=="],
+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 { motion } from "motion/react";
import { import {
@@ -7,6 +9,8 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import DeleteDialog from "./delete-dialog";
import { useState } from "react";
type Props = { type Props = {
id: string; id: string;
@@ -15,69 +19,94 @@ type Props = {
export default function ActionMenu({ id }: Props) { export default function ActionMenu({ id }: Props) {
const buttonClass = 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"; "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 ( return (
<TooltipProvider delayDuration={150}> <>
<motion.div <TooltipProvider delayDuration={150}>
initial={{ opacity: 0, y: 8, scale: 0.95 }} <motion.div
animate={{ opacity: 1, y: 0, scale: 1 }} initial={{ opacity: 0, y: 8, scale: 0.95 }}
exit={{ opacity: 0, y: 8, scale: 0.95 }} animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ exit={{ opacity: 0, y: 8, scale: 0.95 }}
type: "spring", transition={{
stiffness: 450, type: "spring",
damping: 28, 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" id={id}
> />
<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>
); );
} }
+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 { Input } from "./ui/input";
import { Textarea } from "./ui/textarea"; import { Textarea } from "./ui/textarea";
import { Checkbox } from "./ui/checkbox"; import { Checkbox } from "./ui/checkbox";
import { useState } from "react"; import { useState, SubmitEvent } from "react";
import { useQueryClient } from "@tanstack/react-query";
export default function PlusButton() { export default function PlusButton() {
const [differentAddr, setDifferentAddr] = useState(false); const [differentAddr, setDifferentAddr] = useState(false);
const [name, setName] = useState(""); const [open, setOpen] = useState(false);
const [address, setAddress] = useState(""); 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 [description, setDescription] = useState<string>("");
const [sshAddr, setSSHAddr] = useState<string>(""); const [sshAddr, setSSHAddr] = useState<string>("192.168.1.10");
const onSubmit = async () => { const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
let tmp_description = undefined; e.preventDefault();
let tmp_ssh = undefined;
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 (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", { try {
name: name, let app = await invoke("new_app", {
address: address, name: name.trim(),
description: description, address: address.trim(),
sshAddress: sshAddr, 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 ( return (
<Dialog> <Dialog onOpenChange={(e) => setOpen(e.valueOf())} open={open}>
<form onSubmit={() => onSubmit()}> <DialogTrigger asChild>
<DialogTrigger asChild> <motion.button
<motion.button whileHover={{ scale: 1.05 }}
whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}
whileTap={{ scale: 0.95 }} transition={{ type: "spring", stiffness: 300, damping: 20 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }} className="absolute right-3 bottom-3 bg-emerald-700 p-2 rounded-full"
className="absolute right-3 bottom-3 bg-emerald-700 p-2 rounded-full" >
> <Plus className="w-8 h-8 text-white" />
<Plus className="w-8 h-8 text-white" /> </motion.button>
</motion.button> </DialogTrigger>
</DialogTrigger> <DialogContent className="sm:max-w-sm">
<DialogContent className="sm:max-w-sm"> <form onSubmit={handleSubmit}>
<DialogHeader> <DialogHeader>
<DialogTitle>Create Service</DialogTitle> <DialogTitle>Create Service</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -72,8 +93,8 @@ export default function PlusButton() {
id="name" id="name"
name="name" name="name"
value={name} value={name}
onChange={(e) => setName(e.target.value.trim())} // Removed trimming on change to allow spaces while typing
defaultValue="Kubernetes" onChange={(e) => setName(e.target.value)}
/> />
</Field> </Field>
<Field> <Field>
@@ -82,8 +103,7 @@ export default function PlusButton() {
id="address" id="address"
name="address" name="address"
value={address} value={address}
onChange={(e) => setAddress(e.target.value.trim())} onChange={(e) => setAddress(e.target.value)}
defaultValue="192.168.1.10"
/> />
</Field> </Field>
<Field> <Field>
@@ -94,7 +114,7 @@ export default function PlusButton() {
<Textarea <Textarea
id="description" id="description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value.trim())} onChange={(e) => setDescription(e.target.value)}
name="description" name="description"
/> />
</Field> </Field>
@@ -126,8 +146,7 @@ export default function PlusButton() {
id="ssh-addr" id="ssh-addr"
name="ssh-addr" name="ssh-addr"
value={sshAddr} value={sshAddr}
onChange={(e) => setSSHAddr(e.target.value.trim())} onChange={(e) => setSSHAddr(e.target.value)}
defaultValue="192.168.1.10"
/> />
</Field> </Field>
</motion.div> </motion.div>
@@ -136,12 +155,14 @@ export default function PlusButton() {
</FieldGroup> </FieldGroup>
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
<Button variant="outline">Cancel</Button> <Button type="button" variant="outline">
Cancel
</Button>
</DialogClose> </DialogClose>
<Button type="submit">Save changes</Button> <Button type="submit">Save changes</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </form>
</form> </DialogContent>
</Dialog> </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 };
+1 -1
View File
@@ -1,7 +1,7 @@
{ pkgs, ... }: { pkgs, ... }:
{ {
env.DATABASE_URL = "./aperture.db"; env.DATABASE_URL = "../aperture.db";
packages = packages =
with pkgs; with pkgs;
+1
View File
@@ -11,6 +11,7 @@
"format": "biome format --write" "format": "biome format --write"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.101.2",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
Binary file not shown.
+33
View File
@@ -12,3 +12,36 @@ pub fn establish_connection() -> SqliteConnection {
SqliteConnection::establish(&database_url) SqliteConnection::establish(&database_url)
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url)) .unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
} }
pub fn create_app(
conn: &mut SqliteConnection,
name: &str,
address: &str,
description: Option<&str>,
ssh_address: Option<&str>,
) -> models::App {
use crate::db::schema::app;
let new_app = models::App {
id: uuid::Uuid::new_v4().to_string(),
name: name.to_string(),
description: description.map(|d| d.to_string()),
address: address.to_string(),
ssh_address: ssh_address.map(|s| s.to_string()),
};
diesel::insert_into(app::table)
.values(&new_app)
.execute(conn)
.expect("Error saving new app");
new_app
}
pub fn get_apps(conn: &mut SqliteConnection) -> Vec<models::App> {
use crate::db::schema::app;
app::table
.load::<models::App>(conn)
.expect("Error loading apps")
}
+4 -3
View File
@@ -1,6 +1,7 @@
use diesel::prelude::*; use diesel::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Queryable, Selectable, Debug)] #[derive(Queryable, Selectable, Debug, Insertable, Serialize, Deserialize)]
#[diesel(table_name = crate::db::schema::app)] #[diesel(table_name = crate::db::schema::app)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))] #[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct App { pub struct App {
@@ -11,7 +12,7 @@ pub struct App {
pub ssh_address: Option<String>, // default to normal address pub ssh_address: Option<String>, // default to normal address
} }
#[derive(Queryable, Selectable, Debug)] #[derive(Queryable, Selectable, Debug, Serialize, Deserialize)]
#[diesel(table_name = crate::db::schema::blog)] #[diesel(table_name = crate::db::schema::blog)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))] #[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Blog { pub struct Blog {
@@ -21,7 +22,7 @@ pub struct Blog {
pub reference: Option<String>, // uuid pub reference: Option<String>, // uuid
} }
#[derive(Queryable, Selectable, Debug)] #[derive(Queryable, Selectable, Debug, Serialize, Deserialize)]
#[diesel(table_name = crate::db::schema::identities)] #[diesel(table_name = crate::db::schema::identities)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))] #[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Identity { pub struct Identity {
+16 -16
View File
@@ -1,31 +1,31 @@
use db::models::App; use db::models::App;
use uuid::Uuid;
use crate::db::{create_app, establish_connection, get_apps};
mod db; mod db;
#[tauri::command] #[tauri::command]
fn new_app( fn new_app(name: &str, address: &str, description: Option<&str>, ssh_address: Option<&str>) -> App {
name: String, let mut conn = &mut establish_connection();
address: String, let app = create_app(&mut conn, name, address, description, ssh_address);
description: Option<String>,
ssh_address: Option<String>,
) {
let uuid = Uuid::new_v4();
let app = App {
id: uuid.to_string(),
name,
description,
address,
ssh_address,
};
println!("{:#?}", app); println!("{:#?}", app);
app
}
#[tauri::command]
async fn get_app() -> Vec<App> {
let mut conn = &mut establish_connection();
let apps = get_apps(&mut conn);
println!("{:#?}", apps);
apps
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![new_app]) .invoke_handler(tauri::generate_handler![new_app, get_app])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }