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:
@@ -1 +0,0 @@
|
|||||||
DATABASE_URL=./aperture.db
|
|
||||||
+3
-1
@@ -14,4 +14,6 @@ node_modules/
|
|||||||
.next/
|
.next/
|
||||||
|
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
aperture.db
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"cSpell.words": ["tauri"]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{ pkgs, ... }:
|
{ pkgs, ... }:
|
||||||
|
|
||||||
{
|
{
|
||||||
env.DATABASE_URL = "./aperture.db";
|
env.DATABASE_URL = "../aperture.db";
|
||||||
|
|
||||||
packages =
|
packages =
|
||||||
with pkgs;
|
with pkgs;
|
||||||
|
|||||||
@@ -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.
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user