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 (
+
+ );
+}
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 (
-
);
}
diff --git a/components/query-provider.tsx b/components/query-provider.tsx
new file mode 100644
index 0000000..d0f3a74
--- /dev/null
+++ b/components/query-provider.tsx
@@ -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 (
+ {children}
+ );
+}
diff --git a/components/suspence/app-grid.tsx b/components/suspence/app-grid.tsx
new file mode 100644
index 0000000..4d2163b
--- /dev/null
+++ b/components/suspence/app-grid.tsx
@@ -0,0 +1,12 @@
+import { Spinner } from "../ui/spinner";
+
+export default function AppGridFallback() {
+ const commonClasses = "text-black/50 shimmer shimmer-color-emerald-700";
+
+ return (
+
+ );
+}
diff --git a/components/ui/spinner.tsx b/components/ui/spinner.tsx
new file mode 100644
index 0000000..2e43f7d
--- /dev/null
+++ b/components/ui/spinner.tsx
@@ -0,0 +1,16 @@
+import { cn } from "@/lib/utils";
+import { Loader2Icon } from "lucide-react";
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+ return (
+
+ );
+}
+
+export { Spinner };
diff --git a/devenv.nix b/devenv.nix
index e99ed5b..10e7979 100644
--- a/devenv.nix
+++ b/devenv.nix
@@ -1,7 +1,7 @@
{ pkgs, ... }:
{
- env.DATABASE_URL = "./aperture.db";
+ env.DATABASE_URL = "../aperture.db";
packages =
with pkgs;
diff --git a/package.json b/package.json
index 4bbeb48..820e724 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"format": "biome format --write"
},
"dependencies": {
+ "@tanstack/react-query": "^5.101.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"class-variance-authority": "^0.7.1",
diff --git a/src-tauri/aperture.db b/src-tauri/aperture.db
deleted file mode 100644
index ef3acb9..0000000
Binary files a/src-tauri/aperture.db and /dev/null differ
diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs
index 07465e5..8d95636 100644
--- a/src-tauri/src/db/mod.rs
+++ b/src-tauri/src/db/mod.rs
@@ -12,3 +12,36 @@ pub fn establish_connection() -> SqliteConnection {
SqliteConnection::establish(&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 {
+ use crate::db::schema::app;
+
+ app::table
+ .load::(conn)
+ .expect("Error loading apps")
+}
diff --git a/src-tauri/src/db/models.rs b/src-tauri/src/db/models.rs
index 161dc73..5c40e29 100644
--- a/src-tauri/src/db/models.rs
+++ b/src-tauri/src/db/models.rs
@@ -1,6 +1,7 @@
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(check_for_backend(diesel::sqlite::Sqlite))]
pub struct App {
@@ -11,7 +12,7 @@ pub struct App {
pub ssh_address: Option, // default to normal address
}
-#[derive(Queryable, Selectable, Debug)]
+#[derive(Queryable, Selectable, Debug, Serialize, Deserialize)]
#[diesel(table_name = crate::db::schema::blog)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Blog {
@@ -21,7 +22,7 @@ pub struct Blog {
pub reference: Option, // uuid
}
-#[derive(Queryable, Selectable, Debug)]
+#[derive(Queryable, Selectable, Debug, Serialize, Deserialize)]
#[diesel(table_name = crate::db::schema::identities)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Identity {
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 5b4c2d5..47f4747 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,31 +1,31 @@
use db::models::App;
-use uuid::Uuid;
+
+use crate::db::{create_app, establish_connection, get_apps};
mod db;
#[tauri::command]
-fn new_app(
- name: String,
- address: String,
- description: Option,
- ssh_address: Option,
-) {
- let uuid = Uuid::new_v4();
- let app = App {
- id: uuid.to_string(),
- name,
- description,
- address,
- ssh_address,
- };
+fn new_app(name: &str, address: &str, description: Option<&str>, ssh_address: Option<&str>) -> App {
+ let mut conn = &mut establish_connection();
+ let app = create_app(&mut conn, name, address, description, ssh_address);
println!("{:#?}", app);
+ app
+}
+
+#[tauri::command]
+async fn get_app() -> Vec {
+ let mut conn = &mut establish_connection();
+ let apps = get_apps(&mut conn);
+
+ println!("{:#?}", apps);
+ apps
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.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!())
.expect("error while running tauri application");
}