app cards with action menu and tooltips

This commit is contained in:
2026-06-27 00:00:13 -05:00
parent 91ef732eeb
commit 69f269efc7
10 changed files with 236 additions and 16 deletions
+29
View File
@@ -0,0 +1,29 @@
import AppCard from "@/components/app-card";
export default function Dashboard() {
const mockService = {
id: crypto.randomUUID(),
name: "Redis",
address: "127.0.0.1:6379",
};
const mockServiceDesc = {
id: crypto.randomUUID(),
name: "Postgres",
address: "127.0.0.1:5432",
description: "An disk-based database, with full modularity.",
};
return (
<main>
{/* Heading */}
<div>
<h1 className="text-5xl font-bold text-black">Dashboard</h1>
</div>
<div className="mt-4 grid xs:grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<AppCard app={mockService} />
<AppCard app={mockServiceDesc} />
</div>
</main>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function ScriptPage() {
return <h1>Script Page</h1>;
}
+3
View File
@@ -0,0 +1,3 @@
export default function SSHPage() {
return <h1>SSH Page</h1>;
}
+3
View File
@@ -0,0 +1,3 @@
export default function WritePage() {
return <h1>Write Page</h1>;
}
+9
View File
@@ -10,6 +10,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
"motion": "^12.42.0",
"next": "16.2.9", "next": "16.2.9",
"radix-ui": "^1.6.0", "radix-ui": "^1.6.0",
"react": "19.2.4", "react": "19.2.4",
@@ -577,6 +578,8 @@
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"framer-motion": ["framer-motion@12.42.0", "", { "dependencies": { "motion-dom": "^12.42.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-wp7EJnfWaaEScVygKv3e20udoRz+LbtxScsuTkakAxfXmt+ReC6WyPW2nINRAGvd+hG9odwcjBLyOTPjH5pBRA=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
@@ -741,6 +744,12 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"motion": ["motion@12.42.0", "", { "dependencies": { "framer-motion": "^12.42.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Qhwvu9sVl5/URSq5CNzwMCpSKK8Uhnrwb6VO977kZyj/wOCS7mWebJUnBoHx5cZU1Zv8a9BD5CSICWKAlrLJgA=="],
"motion-dom": ["motion-dom@12.42.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-M63h4n8R+quJdNhBwuLlgxM+OLYa9+I/T2pzDRboB9fLXRdbou+Gw7Zury+SkpaCyACP1JHSjHgZ1EgTkBr30w=="],
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],
+83
View File
@@ -0,0 +1,83 @@
import { Terminal, Pencil, FileTerminal } from "lucide-react";
import { motion } from "motion/react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
type Props = {
id: string;
};
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";
return (
<TooltipProvider delayDuration={150}>
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.95 }}
transition={{
type: "spring",
stiffness: 450,
damping: 28,
}}
className="flex justify-end"
>
<div className="flex flex-row justify-end gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<button
className={buttonClass}
onClick={() => {
// SSH
}}
>
<Terminal className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>SSH</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className={buttonClass}
onClick={() => {
// Blog
}}
>
<Pencil className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Create Blog</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className={buttonClass}
onClick={() => {
// Scripts
}}
>
<FileTerminal className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Run Scripts</p>
</TooltipContent>
</Tooltip>
</div>
</motion.div>
</TooltipProvider>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { motion, AnimatePresence } from "motion/react";
import { useState } from "react";
import ActionMenu from "./action-menu";
type App = {
id: string;
name: string;
description?: string;
address: string;
ssh_addr?: string;
};
interface Props {
app: App;
}
export default function AppCard({ app }: Props) {
let pingStatus = true;
const [hovered, setHovered] = useState(false);
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={`
relative
rounded-2xl
bg-gray-100
p-3
${hovered && "border bg-white"}
transition-all
duration-200
`}
>
<div className="absolute top-4 right-4">
<div className="relative">
<div className="absolute inset-0 rounded-full bg-green-500 animate-ping opacity-30" />
<div className="relative h-2.5 w-2.5 rounded-full bg-green-500 ring-2 ring-white" />
</div>
</div>
<h3 className="text-2xl font-bold">{app.name}</h3>
<a
className={`${hovered && "text-blue-600"} transition-colors duration-200`}
target="_blank"
href={`http://${app.address}`}
>
{app.address}
</a>
{/*<AnimatePresence>
{hovered && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2 }}
>
{app.description && <span>{app.description}</span>}
<ActionMenu id={app.id} />
</motion.div>
)}
</AnimatePresence>*/}
<AnimatePresence>
{hovered && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className={`absolute bottom-3 left-3 right-3 ${app.description && "bg-white"} pt-2 flex flex-col gap-1`}
>
{app.description && (
<span className="text-sm text-muted-foreground">
{app.description}
</span>
)}
<ActionMenu id={app.id} />
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+17 -15
View File
@@ -1,10 +1,5 @@
"use client"; "use client";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupLabel, SidebarGroupLabel,
@@ -12,6 +7,7 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import Link from "next/link";
export function NavMain({ export function NavMain({
items, items,
@@ -25,18 +21,24 @@ export function NavMain({
}) { }) {
return ( return (
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel> <SidebarGroupLabel>Aperture</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
{items.map((item) => ( {items.map((item) => (
<SidebarMenuItem <Link href={item.url} key={item.title}>
key={item.title} <SidebarMenuItem>
className="hover:bg-back hover:border-0" <SidebarMenuButton
> tooltip={item.title}
<SidebarMenuButton tooltip={item.title}> className={
{item.icon} item.isActive
<span>{item.title}</span> ? "bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-900 data-[active=true]:bg-emerald-500/20"
</SidebarMenuButton> : ""
</SidebarMenuItem> }
>
{item.icon}
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</Link>
))} ))}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
+1 -1
View File
@@ -19,7 +19,7 @@ export default function Sidebar({ children }: Props) {
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
</div> </div>
</header> </header>
<main className="flex-1 overflow-y-auto">{children}</main> <main className="flex-1 overflow-y-auto ml-4">{children}</main>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
); );
+1
View File
@@ -16,6 +16,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
"motion": "^12.42.0",
"next": "16.2.9", "next": "16.2.9",
"radix-ui": "^1.6.0", "radix-ui": "^1.6.0",
"react": "19.2.4", "react": "19.2.4",