From 77e40752ece0e825ebe1ce286495c8f81a979bdf Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Thu, 18 Sep 2025 22:40:36 -0700 Subject: [PATCH 1/8] User panel refresh. Also added documentation for both users and admins. Lastly, removed user registration. --- my-app/app/admin/admin-sidebar.tsx | 14 +- my-app/app/admin/guides/templates/page.tsx | 413 ++++++++++- my-app/app/admin/guides/users/page.tsx | 181 ++++- .../admin/pods/templates/publish/step-one.tsx | 11 +- my-app/app/admin/vms/vm-table-toolbar.tsx | 2 +- my-app/app/info/page.tsx | 314 +++++++- my-app/app/login/login-form.tsx | 5 +- my-app/app/page.tsx | 415 +++++++---- .../app/pods/deployed/deployed-pods-cards.tsx | 340 ++++++--- my-app/app/pods/deployed/page.tsx | 4 +- my-app/app/pods/templates/page.tsx | 4 +- .../pods/templates/pod-templates-cards.tsx | 58 +- my-app/app/register/page.tsx | 54 -- my-app/app/register/register-form.tsx | 249 ------- my-app/components/footer.tsx | 64 +- my-app/components/pod-deploy-dialog.tsx | 112 ++- .../components/shared/deploy-pod-dialog.tsx | 27 - my-app/components/ui/markdown-renderer.tsx | 34 +- my-app/components/ui/timeline.tsx | 205 ++++++ my-app/lib/api.ts | 10 + my-app/lib/types.ts | 6 + my-app/lib/utils.ts | 14 +- my-app/package-lock.json | 670 +++++++++++++++--- my-app/package.json | 2 + my-app/public/1-Kamino-Template-Pool.png | Bin 0 -> 254794 bytes my-app/public/1-Uploading-ISO.png | Bin 0 -> 260932 bytes my-app/public/1-Windows-VM-OS.png | Bin 0 -> 162849 bytes my-app/public/2-Creating-VNET.png | Bin 0 -> 221475 bytes my-app/public/2-Linux-VM-Via-ISO.png | Bin 0 -> 272736 bytes my-app/public/2-Windows-VM-Load-Driver.png | Bin 0 -> 123611 bytes my-app/public/3-Linux-VM-General.png | Bin 0 -> 74461 bytes my-app/public/3-VNET-Dialog.png | Bin 0 -> 42467 bytes my-app/public/3-Windows-VM-AMD64.png | Bin 0 -> 162824 bytes my-app/public/4-Linux-VM-OS.png | Bin 0 -> 117340 bytes my-app/public/4-SDN-Apply.png | Bin 0 -> 186626 bytes my-app/public/4-Windows-VM-Virtio.png | Bin 0 -> 721959 bytes my-app/public/5-Linux-VM-Disks.png | Bin 0 -> 126259 bytes my-app/public/6-Linux-VM-Network.png | Bin 0 -> 92970 bytes 38 files changed, 2364 insertions(+), 844 deletions(-) delete mode 100644 my-app/app/register/page.tsx delete mode 100644 my-app/app/register/register-form.tsx create mode 100644 my-app/components/ui/timeline.tsx create mode 100644 my-app/public/1-Kamino-Template-Pool.png create mode 100644 my-app/public/1-Uploading-ISO.png create mode 100644 my-app/public/1-Windows-VM-OS.png create mode 100644 my-app/public/2-Creating-VNET.png create mode 100644 my-app/public/2-Linux-VM-Via-ISO.png create mode 100644 my-app/public/2-Windows-VM-Load-Driver.png create mode 100644 my-app/public/3-Linux-VM-General.png create mode 100644 my-app/public/3-VNET-Dialog.png create mode 100644 my-app/public/3-Windows-VM-AMD64.png create mode 100644 my-app/public/4-Linux-VM-OS.png create mode 100644 my-app/public/4-SDN-Apply.png create mode 100644 my-app/public/4-Windows-VM-Virtio.png create mode 100644 my-app/public/5-Linux-VM-Disks.png create mode 100644 my-app/public/6-Linux-VM-Network.png diff --git a/my-app/app/admin/admin-sidebar.tsx b/my-app/app/admin/admin-sidebar.tsx index 7b67aa9..d30b5cf 100644 --- a/my-app/app/admin/admin-sidebar.tsx +++ b/my-app/app/admin/admin-sidebar.tsx @@ -16,7 +16,7 @@ import { SidebarMenuItem, SidebarFooter, } from "@/components/ui/sidebar" -import { Camera, Copy, CopyPlusIcon, Edit, LayoutDashboard, Rocket, Server, User } from "lucide-react" +import { Copy, CopyPlusIcon, Edit, LayoutDashboard, Rocket, Server, User } from "lucide-react" import { IconUsersGroup } from "@tabler/icons-react" const data = { @@ -98,7 +98,7 @@ const data = { url: "#", items: [ { - title: "Users", + title: "Users & Groups", url: "/admin/guides/users", isActive: false, icon: User @@ -107,14 +107,8 @@ const data = { title: "Templates", url: "/admin/guides/templates", isActive: false, - icon: CopyPlusIcon + icon: Copy }, - { - title: "Snapshots", - url: "/admin/guides/snapshots", - isActive: false, - icon: Camera - } ], } ], @@ -128,7 +122,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + Logo
diff --git a/my-app/app/admin/guides/templates/page.tsx b/my-app/app/admin/guides/templates/page.tsx index b01f9e5..7f98781 100644 --- a/my-app/app/admin/guides/templates/page.tsx +++ b/my-app/app/admin/guides/templates/page.tsx @@ -2,8 +2,13 @@ import { AuthGuard } from "@/components/auth-guard" import { PageLayout } from "@/app/admin/admin-page-layout" +import { Card, CardContent } from "@/components/ui/card" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Server, Package, Settings, Upload, Info, TriangleAlert } from "lucide-react" +import Link from "next/link" +import Image from "next/image" -const breadcrumbs = [{ label: "Templates Guide", href: "/admin/guides/templates" }] +const breadcrumbs = [{ label: "Template Management Guide", href: "/admin/guides/templates" }] export default function AdminTemplatesGuide() { @@ -13,14 +18,406 @@ export default function AdminTemplatesGuide() { breadcrumbs={breadcrumbs} >
-
-
-

Templates

-

- Placeholder -

+
+
+
+

Template Creation & Management

+

+ Complete guide for creating, publishing, and managing VM templates in Kamino through Proxmox integration +

+
+ + {/* Template Workflow Infographic */} + + +
+
+ +
Proxmox
+
Create Template
+
+
+
+
+
+ +
Admin Panel
+
Publish Template
+
+
+
+
+
+ +
Admin Panel
+
Manage Template
+
+
+
+
+ + {/* Creating Templates Section */} +
+
+
+

+ + Step 1: Create Template Infrastructure in Proxmox +

+ +
+ {/* Pool Creation */} +
+

+ + 1.1 Create Template Pool +

+

+ Pools in Proxmox help organize and manage related VMs. All template VMs must be placed in a designated pool with the correct naming convention. +

+
    +
  1. Navigate to Datacenter → Pools in the Proxmox web interface
  2. +
  3. Click “Create” to add a new pool
  4. +
  5. Use the naming convention: kamino_template_[template_name]
  6. +
  7. Example: kamino_template_ubuntu_lab → Template name will be “ubuntu lab”
  8. +
+ +
+ Proxmox Create Pool - Shows the pool creation dialog with naming convention +
+
+ + {/* VNet Creation */} +
+

+ + 1.2 Create Template Virtual Network (VNet) +

+

+ Each template requires its own isolated network to prevent conflicts and ensure proper network segmentation. +

+
    +
  1. Navigate to Datacenter → SDN → VNets
  2. +
  3. Click “Create” to add a new virtual network
  4. +
  5. Next, Name the VNet. Keep in mind the name has a limit of just 8 characters
  6. +
  7. Choose a unique VLAN tag above 1000 (e.g., 1001, 1002, etc.)
  8. +
  9. Important: After creating the VNet, go to Datacenter → SDN and click “Apply” to activate the changes
  10. +
+ + + + Warning
+ + Ensure your VLAN tag is unique across all templates and above 1000 to avoid conflicts with system networks. + +
+ +
+
+ Proxmox Create VNet - Main VNet creation interface +

VNet creation interface in Proxmox SDN

+
+
+
+ Proxmox VNet Dialog - Configuration options for the virtual network +

VNet configuration dialog

+
+
+ Proxmox Apply SDN Changes - Applying network configuration changes +

Applying SDN configuration changes

+
+
+
+
+ + {/* VM Creation */} +
+

+ + 1.3 Create and Configure Virtual Machines +

+

+ Set up all VMs within the created pool with your desired operating systems and configurations. Follow the specific steps below for different OS types. +

+ + {/* ISO Upload */} +
+
Uploading ISO Files
+ + + + Info + + Prior to uploading an ISO, check if your OS is not already a template in the Templates pool in Proxmox these templates should have VmIDs in the range of the 4000s. To use these, simply clone the desired template into your pool. + + + +

+ Upload the necessary ISO files to your mufasa-proxmox. +

+ Proxmox Uploading ISO - Process for uploading ISO files to Proxmox storage +

Uploading ISO files to Proxmox storage

+
+ + {/* Linux VM Configuration */} +
+
Linux VM Configuration
+

+ Follow these steps to create a Linux-based virtual machine. Ensure you select the appropriate settings for optimal performance. +

+
+
+ Linux VM Creation - Initial VM creation from ISO +

Creating a new Linux VM from ISO

+
+
+
+ Linux VM General Settings - Basic VM configuration options +

General VM settings and pool assignment

+
+
+ Linux VM OS Settings - Operating system configuration +

Operating system selection and configuration

+
+
+
+
+ Linux VM Disk Settings - Storage configuration for the VM +

Disk configuration and storage allocation

+
+
+ Linux VM Network Settings - Network configuration using the created VNet +

Network configuration with the template VNet

+
+
+
+
+ + {/* Windows VM Configuration */} +
+
Windows VM Configuration
+

+ Windows VMs require specific drivers and configurations for optimal performance in a virtualized environment. Pay attention to driver selection and hardware compatibility. +

+
+
+
+ Windows VM OS Selection - Choosing Windows operating system type +

Windows OS type selection

+
+
+ Windows VM Driver Loading - Loading VirtIO drivers for better performance +

Loading VirtIO drivers during installation

+
+
+ Windows VM Architecture - Selecting appropriate architecture +

Architecture and system type selection

+
+
+ Windows VM VirtIO Configuration - Final VirtIO driver configuration +

VirtIO driver configuration for optimal performance

+
+
+
+
+
+ + {/* VM Preparation */} +
+

+ + 1.4 Prepare VMs for Template Conversion +

+

+ Before converting VMs to templates, proper cleanup and optimization ensures better performance and smaller template sizes. +

+
    +
  1. Complete Shutdown: Ensure all VMs are completely shut down before proceeding
  2. +
+
+
+
+ +
+

+ + Step 2: Publish Template in Kamino Admin Panel +

+ +

+ Once your VMs are properly configured and prepared, use the Kamino admin panel to convert them into deployable templates. This process will convert the VMs to Proxmox templates and make them available for users to deploy. +

+ + + + Warning
+ + Publishing a template will automatically: +
    +
  • Remove all snapshots from VMs in the Proxmox pool
  • +
  • Convert VMs to read-only VM templates in Proxmox
  • +
  • Make future VM modifications extremely difficult
  • +
+ Ensure all configurations are finalized before proceeding. +
+
+ +
+

+ + Publishing Process +

+ +
    +
  1. + Navigate to Publishing Interface: In the Kamino admin panel, go to{' '} + + Publish Templates + {' '} + section +
  2. +
  3. + Configure Template Details: Complete all required information fields: +
      +
    • Template Name: Descriptive name for the template (auto-generated from pool name)
    • +
    • Description: Detailed explanation of what the template contains and its intended use
    • +
    • Template Image: Upload a representative image or logo for the template
    • +
    • Authors: List of template creators or maintainers
    • +
    • VM Count: Number of virtual machines included in the template
    • +
    • Visibility: Set initial visibility (Public or Private)
    • +
    +
  4. +
  5. + Review Template Preview: Carefully review all template information and settings in the preview +
  6. +
  7. + Confirm Creation: Click “Publish Template” to finalize the process +
  8. +
+
+
+ +
+

+ + Step 3: Manage and Maintain Templates +

+ + + + Info + + Deleting a template will not delete the underlying VMs in Proxmox. You must manually delete the VMs from Proxmox if you wish to remove them. + + + +
    +
  1. Admin Panel: In the Kamino admin panel, navigate to the All Templates section
  2. +
  3. Manage: Click on the actions dropdown menu on the far right of the table row. You can edit all of the template details, quickly toggle the visibility of the template, or delete the template.
  4. +
+
+ +
+
- {/* content */}
diff --git a/my-app/app/admin/guides/users/page.tsx b/my-app/app/admin/guides/users/page.tsx index af2372d..940f1fa 100644 --- a/my-app/app/admin/guides/users/page.tsx +++ b/my-app/app/admin/guides/users/page.tsx @@ -2,8 +2,12 @@ import { AuthGuard } from "@/components/auth-guard" import { PageLayout } from "@/app/admin/admin-page-layout" +import { Card, CardContent } from "@/components/ui/card" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Settings, Database, Server, Info, Plus, Minus } from "lucide-react" +import Link from "next/link" -const breadcrumbs = [{ label: "Users Guide", href: "/admin/guides/users" }] +const breadcrumbs = [{ label: "Users & Groups Guide", href: "/admin/guides/users" }] export default function AdminUsersGuide() { @@ -13,14 +17,179 @@ export default function AdminUsersGuide() { breadcrumbs={breadcrumbs} >
-
+
+
-

Users

-

- Placeholder +

User & Group Management

+

+ Comprehensive guide for managing users and groups through Active Directory with automated Proxmox synchronization

- {/* content */} + + {/* System Architecture Infographic */} + + +
+
+ +
Admin Panel
+
User Management
+
+
+
+
+
+ +
Active Directory
+
LDAP Operations
+
+
+
+
+
+ +
Proxmox
+
VM Access Control
+
+
+
+
+ + {/* User Management Section */} +
+

User Management

+ +
+

+ Users are created, modified, and removed through the Users Kamino admin panel page. All changes are synchronized with Active Directory and Proxmox to ensure consistent access control. +

+
+ + {/* Alert for Usernames and Passwords */} + + + Info + +

Usernames

+
    +
  • Must be unique across the entire Active Directory domain
  • +
  • Must be between 3-20 characters long
  • +
  • Can include letters and numbers
  • +
+

Passwords

+
    +
  • Must be between 8-128 characters long
  • +
  • Must include at least one letter and number
  • +
+
+
+ +
+
+

+ + Creating Users +

+
    +
  1. + Create New User: Click “Add User” and fill in required details. There are two methods of user creation: +
      +
    • Single: Create a single user with a simple username and password
    • +
    • Bulk: Input a comma and newline separated list of usernames and passwords
    • +
    +
  2. +
+
+ +
+

+ + Modifying Users +

+
    +
  1. Group Membership: Add or remove the user from groups to change access permissions. Groups that contain “Kamino” or “Admin” cannot be modified
  2. +
+
+ +
+

+ + Removing Users +

+
    +
  1. Disable Account: Disable the account to prevent access
  2. +
  3. Delete Account: Remove the account from Active Directory and all connected systems
  4. +
+
+ +
+
+ + {/* Group Management Section */} +
+

Group Management

+ +
+

+ Groups are created, modified, and removed through the Groups Kamino admin panel page. All changes are synchronized with Active Directory and Proxmox to ensure consistent access control. +

+
+ + + + Info + +

Group Names

+
    +
  • Must be unique across the entire Active Directory domain
  • +
  • Must be between 3-63 characters long
  • +
  • Can include letters, numbers, hyphens, and underscores
  • +
  • Groups containing “Kamino” or “Admin” cannot be modified or removed
  • +
+
+
+ +
+
+

+ + Creating Groups +

+
    +
  1. + Create New Group: Click “Add Group” and specify the group name. There are three methods of group creation: +
      +
    • Single: Create a single group with a unique name
    • +
    • Bulk: Input a newline separated list of group names
    • +
    • Prefix: Create multiple groups with a common prefix and a numbered suffix
    • +
    +
  2. +
+
+ +
+

+ + Modifying Groups +

+
    +
  1. Edit Properties: Rename the group
  2. +
+
+ +
+

+ + Removing Groups +

+
    +
  1. Delete Group: Select the group to be removed and click “Delete”
  2. +
+
+ +
+
+
diff --git a/my-app/app/admin/pods/templates/publish/step-one.tsx b/my-app/app/admin/pods/templates/publish/step-one.tsx index 94d9d46..22c1a5a 100644 --- a/my-app/app/admin/pods/templates/publish/step-one.tsx +++ b/my-app/app/admin/pods/templates/publish/step-one.tsx @@ -109,9 +109,14 @@ export function StepOne({ selectedTemplate, onTemplateSelect, onNext }: StepOneP Notice - Publishing this template will convert all VMs in the pool to templates. - Once converted, it will be very difficult to make edits to the environment. - Please ensure you have completed all necessary configurations before proceeding. +
+

This will attempt to convert all VMs to templates, meaning:

+
    +
  • No further changes can be made to the VMs
  • +
  • All running VMs will be shutdown
  • +
  • All VM snapshots will be deleted
  • +
+
)} diff --git a/my-app/app/admin/vms/vm-table-toolbar.tsx b/my-app/app/admin/vms/vm-table-toolbar.tsx index ad23e75..44a586f 100644 --- a/my-app/app/admin/vms/vm-table-toolbar.tsx +++ b/my-app/app/admin/vms/vm-table-toolbar.tsx @@ -70,7 +70,7 @@ export function VMTableToolbar({
onSearchChange(e.target.value)} className="pl-8 bg-background" diff --git a/my-app/app/info/page.tsx b/my-app/app/info/page.tsx index f6ca6f7..68f104d 100644 --- a/my-app/app/info/page.tsx +++ b/my-app/app/info/page.tsx @@ -2,21 +2,323 @@ import { PageLayout } from "@/components/user-page-layout" import { AuthGuard } from "@/components/auth-guard" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { Server, Users, GitBranch, Cloud, Star, ChartColumnBigIcon, Rocket, Copy, ExternalLink, Calendar } from "lucide-react" +import Image from "next/image" +import Link from "next/link" +import { + Timeline, + TimelineContent, + TimelineDate, + TimelineHeader, + TimelineIndicator, + TimelineItem, + TimelineSeparator, + TimelineTitle, +} from "@/components/ui/timeline" export default function Page() { const pageHeader = ( -
-

Kamino

-

- What is Kamino? -

+
+
+

Kamino

+
) + const features = [ + { + icon: , + title: "Pod Cloning", + description: "Easily clone and interact with pods of VMs" + }, + { + icon: , + title: "Template Library", + description: "Access a library of pre-defined templates made by students and faculty" + }, + { + icon: , + title: "Resource Management", + description: "Monitor the status of pod VMs or interact with them directly in Proxmox" + }, + { + icon: , + title: "User-Friendly Interface", + description: "Intuitive UI that simplifies VM pod management" + } + ] + + const timeline = [ + { + id: 1, + date: "Aug 2023", + title: "Project Kickoff", + description: + "Idea conception and initial implementation for VMware vSphere.", + contributors: ["Gabriel Fok", "Evan Deters", "Dylan Tran"], + }, + { + id: 2, + date: "Mar 2025", + title: "Proxmox Rebuild", + description: + "Planning and implementation to make cloning work with Proxmox, enhancing compatibility and performance.", + contributors: ["Dylan Michalak", "Luke Kimes"], + }, + { + id: 3, + date: "Jul 2025", + title: "New Kamino", + description: + "Complete backend and frontend rewrite to support Proxmox, Users, and Groups management.", + contributors: ["Maxwell Caron", "Marshall Ung"], + } +] + return ( -

WIP

+ {/* Grid Layout */} +
+ + {/* What is Kamino */} + + +
+ Kamino Logo + What is Kamino? +
+
+ +

+ Kamino is a modern platform that allows users to deploy and manage pods of virtual machines (VMs) with ease. + It provides a user-friendly interface to clone, destroy, and monitor VM pods, making it ideal for users with + any level of expertise who want to quickly get hands on with a variety of different virtualized environments. +

+
+
+ + {/* Features */} + + + + + Features + + + Powerful tools and capabilities to streamline your VM management workflow + + + +
+ {features.map((feature, index) => ( +
+
+
+ {feature.icon} +
+
+

{feature.title}

+

{feature.description}

+
+
+
+ ))} +
+
+
+ + {/* Stats */} + + + + + Stats + + + +
+
200+
+
Users
+
+ +
+
50+
+
Deployed Pods
+
+ +
+
600+
+
Deployed VMs
+
+ +
+
New
+
Weekly Templates
+
+
+
+ + {/* Lifetime */} + + + + + Lifetime + + + A quick look at Kamino’s milestones along with the students who made it possible + + + + + {timeline.map((item) => ( + + + + {item.date} + {item.title} + + + +
+

{item.description}

+ {item.contributors && item.contributors.length > 0 && ( +
+ {item.contributors.map((contributor, index) => ( + + {contributor} + + ))} +
+ )} +
+
+
+ ))} +
+
+
+ + {/* Infrastructure */} + + + + + Infrastructure + + + Understanding Kamino’s distributed microservices architecture and technology stack + + + +
+ {/* Repository Cards */} +
+ +
+
+
+ +
+
+
+

kamino-frontend

+ +
+

React-based user interface

+
+
+

+ Provides the complete user experience with dashboards, VM management interfaces, and real-time monitoring. + Built with Next.js and shadcn/ui for a modern, responsive interface. +

+
+ + React + + + Next.js + + + shadcn/ui + +
+
+ + + +
+
+
+ +
+
+
+

proclone

+ +
+

Core backend API

+
+
+

+ Handles all VM lifecycle operations, resource allocation, and API endpoints. Manages pod creation, template processing, + and communicates with underlying virtualization infrastructure. +

+
+ + Golang + + + Proxmox + + + MariaDB + +
+
+ + + +
+
+
+ +
+
+
+

proclone-deployment

+ +
+

Infrastructure automation

+
+
+

+ Contains Kubernetes manifests and ArgoCD configurations for automated deployment and scaling. + Ensures consistent environments across development and production. +

+
+ + Docker + + + Kubernetes + + + ArgoCD + +
+
+ +
+
+
+
+ +
) diff --git a/my-app/app/login/login-form.tsx b/my-app/app/login/login-form.tsx index 149cf8a..6c17f31 100644 --- a/my-app/app/login/login-form.tsx +++ b/my-app/app/login/login-form.tsx @@ -7,7 +7,6 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { useRouter } from "next/navigation" import { useAuth } from "@/contexts/auth-context" -import Link from "next/link" interface FormData { username: string @@ -115,7 +114,7 @@ export function LoginForm({ -
+ {/*
Don't have an account?{" "} Sign up -
+
*/}
) diff --git a/my-app/app/page.tsx b/my-app/app/page.tsx index 3ca2959..2827953 100644 --- a/my-app/app/page.tsx +++ b/my-app/app/page.tsx @@ -13,30 +13,42 @@ import { Separator } from "@/components/ui/separator" import Link from "next/link" import Image from "next/image" import { useApiState } from "@/hooks/use-api-state" -import { getPodTemplates, getDeployedPods } from "@/lib/api" +import { getUserDashboard } from "@/lib/api" import { LoadingSpinnerSmall } from "@/components/ui/loading-spinner" import { ErrorDisplay } from "@/components/ui/error-display" import { Button } from "@/components/ui/button" -import { Eye } from "lucide-react" +import { Eye, Boxes, Rocket, User, Calendar, Copy } from "lucide-react" import { Badge } from "@/components/ui/badge" import { PodDeployDialog } from "@/components/pod-deploy-dialog" import { usePodDeployment } from "@/hooks/use-pod-deployment" +import { IconPlayerPlayFilled, IconUsersGroup } from "@tabler/icons-react" +import { formatPodName, formatRelativeTime, formatDateTime } from "@/lib/utils" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { useAuth } from "@/contexts/auth-context" +import { Group } from "@/lib/types" export default function Page() { const { isDialogOpen, selectedPod, openDeployDialog, closeDeployDialog } = usePodDeployment() + const { authState } = useAuth() - const { data: deployedPods, loading: deployedLoading, error: deployedError } = useApiState({ - fetchFn: getDeployedPods, + // Single API call to get all dashboard data + const { data: dashboardData, loading: dashboardLoading, error: dashboardError } = useApiState({ + fetchFn: getUserDashboard, }) - const { data: podTemplates, loading: podTemplatesLoading, error: podTemplatesError } = useApiState({ - fetchFn: getPodTemplates, - }) + // Extract data from the unified response + const deployedPods = dashboardData?.pods || [] + const podTemplates = dashboardData?.templates || [] + const currentUserData = dashboardData?.user_info || null + + // Computed loading and error states + const isLoading = dashboardLoading + const hasError = dashboardError const pageHeader = (
-

Kamino Dashboard

-

+

Kamino Dashboard

+

Browse, deploy, and manage your own instance of our curated interactive pod environments

@@ -45,140 +57,277 @@ export default function Page() { return ( - {/* Deployed Pods Section */} - - - - My Deployed Pods -
- Pods you have already deployed and can interact with -
-
- - - -
- - - {deployedLoading && } - {deployedError && } - {!deployedLoading && !deployedError && deployedPods && ( - <> - {deployedPods.length === 0 ? ( -
- No deployed pods found. + {/* Grid Layout - Deployed Pods and User Profile */} +
+ {/* User Profile Card - 1/3 width on desktop, full width on mobile, appears first on mobile */} +
+ + + + Profile +

+ Your account information +

+
+ + + +
+ + +
+
+

+ {authState.username || "User"} +

+

+ {authState.isAdmin ? "Administrator" : "General User"} +

- ) : ( -
- {deployedPods.slice(0, 3).map((pod, index) => ( - -
-
- {`${pod.name} -
-
-

{pod.template?.name ? pod.template.name.replaceAll('_', ' ') : pod.name}

-
- {/* Deployed {new Date(pod.deployed_at).toLocaleDateString()} */} + +
+ + {/* Deployed Pods */} +
+ + + Deployed Pods + + + {deployedPods?.length || 0} + +
+ + + + {/* Groups */} +
+ + + Groups + + {isLoading ? ( + + ) : currentUserData?.groups ? ( + + + + {currentUserData.groups.length} + + + + {currentUserData.groups.map((group: Group) => ( +

{group.name}

+ ))} +
+
+ ) : ( + + Unknown + + )} +
+ + + + {/* Member Since */} +
+ + + Member Since + + {isLoading ? ( + + ) : currentUserData?.created_at ? ( + + + + {formatRelativeTime(currentUserData.created_at)} + + + +

{formatDateTime(currentUserData.created_at)}

+
+
+ ) : ( + + Unknown + + )} +
+ +
+
+ + +
+ + {/* Deployed Pods Card - 2/3 width on desktop, full width on mobile, appears second on mobile */} +
+ + + + + + My Deployed Pods + +

+ Pods you have already deployed and can interact with +

+
+ + + +
+ + + {isLoading &&
} + {hasError && } + {!isLoading && !hasError && deployedPods && ( + <> + {deployedPods.length === 0 ? ( +
+ No deployed pods found. +
+ ) : ( +
+ {deployedPods.slice(0, 3).map((pod, index) => ( +
+ +
+
+ {`${pod.name} +
+
+

{formatPodName(pod.template?.name || pod.name)}

+
+ + + {pod.vms.length} {pod.vms.length === 1 ? 'VM' : 'VMs'} + + {pod.vms.filter(vm => vm.status === 'running').length > 0 && ( + + + {pod.vms.filter(vm => vm.status === 'running').length} Running + + )} +
+
+
-
- - ))} - {deployedPods.length > 3 && ( -
- - View {deployedPods.length - 3} more deployed pods - + ))} + {deployedPods.length > 3 && ( +
+ + View {deployedPods.length - 3} more deployed pods + +
+ )}
)} -
+ )} - - )} - - + + +
+
{/* Pod Templates Section */} - - - - Pod Templates -
- Pre-made pods of virtual machines that are then turned into templates and available for users to clone -
-
- - - -
- - - {podTemplatesLoading && } - {podTemplatesError && } - {!podTemplatesLoading && !podTemplatesError && podTemplates && ( - <> - {podTemplates.length === 0 ? ( -
- No pod templates found. -
- ) : ( -
- {podTemplates.slice(0, 3).map((template, index) => ( -
openDeployDialog(template)} - className="flex items-center gap-4 p-4 bg-card rounded-xl w-full cursor-pointer transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" - role="button" - > -
- {template.name} -
-
-

{template.name}

-
- {(template.vm_count || 0)} {template.vm_count === 1 ? "VM" : "VMs"} - {template.deployments} Deployments +
+ + + + + + Pod Templates + +

+ Library of templates created by students and faculty, available for users to clone and interact with. +

+
+ + + +
+ + + {isLoading && } + {hasError && } + {!isLoading && !hasError && podTemplates && ( + <> + {podTemplates.length === 0 ? ( +
+ No pod templates found. +
+ ) : ( + <> +
+ {podTemplates.slice(0, 3).map((template, index) => ( +
openDeployDialog(template)} + className="flex flex-col p-4 bg-card rounded-xl cursor-pointer border-2 border-card shadow-md dark:shadow-primary/5 hover:border-primary/15 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none transition-colors h-full" + role="button" + > +
+
+ {template.name} +
+

{formatPodName(template.name)}

+
+
+
+ + + {(template.vm_count || 0)} {template.vm_count === 1 ? "VM" : "VMs"} + + + + {template.deployments} Deployments + +
+
-
+ ))}
- ))} - {podTemplates.length > 3 && ( -
- - View {podTemplates.length - 3} more pod templates - -
- )} -
- )} - - )} - - + {podTemplates.length > 3 && ( +
+ + View {podTemplates.length - 3} more pod templates + +
+ )} + + )} + + )} + + +
{/* Centralized Deploy Dialog */} {formatUptime(currentUptime)} } - type SectionCardsProps = { pods: DeployedPod[] onDelete: (pod: DeployedPod) => void @@ -47,145 +56,256 @@ export function SectionCards({ pods, onDelete }: SectionCardsProps) { } return ( -
+
{pods.map((pod, index) => { const vms = pod.vms || [] - // Determine grid columns based on VM count - const gridCols = vms.length >= 6 ? "grid-cols-2" : "grid-cols-1" return (
{/* Main Card */} - - - {/* Header row */} -
- - {/* Image */} -
-
- Kamino Logo + + + {/* Header row */} +
+ + {/* Image */} +
+
+ Pod Template +
-
- {/* Info */} -
- - {/* Delete Pod Button */} + {/* Info */} +
+ + {/* Delete Pod Button */} - - {/* Date and Title */} -
-

- - {pod.template?.created_at ? new Date(pod.template.created_at).toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }) : new Date().toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - })} -

-

- {pod.template?.name ? pod.template?.name : pod.name} + + + + {/* Date */} +
+ + + Published {pod.template?.created_at ? new Date(pod.template.created_at).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }) : new Date().toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + })} + +
+ + {/* Title */} +

+ {formatPodName(pod.template?.name || pod.name)}

+ + {/* Pod Stats */} +
+ + + {vms.length} {vms.length === 1 ? 'VM' : 'VMs'} + + {vms.filter(vm => vm.status === 'running').length > 0 && ( + + + {vms.filter(vm => vm.status === 'running').length} Running + + )} +

-
{/* Main content */} -
+ {/* VMs */} -
-

Virtual Machines

-
-
- {vms.length > 0 ? ( - vms.map((vm) => ( - + + Virtual Machines + + +
+ {vms.length > 0 ? ( + vms.map((vm) => ( + -
-
- -

{vm.name}

+ className="block group" + > + + +
+
+ +

+ {vm.name} +

+
+ + + + +
+
+ +
+ {/* CPU Usage */} +
+
+
+ + CPU +
+ + {vm.cpu || vm.cpu === 0 ? `${(vm.cpu * 100).toFixed(1)}%` : 'N/A'} of {vm.maxcpu || 'N/A'} cores + +
+ +
+ + {/* Memory Usage */} +
+
+
+ + Memory +
+ + {vm.mem ? formatBytes(vm.mem) : 'N/A'} / {vm.maxmem ? formatBytes(vm.maxmem) : 'N/A'} + +
+ +
+ + {/* Storage Total */} +
+
+
+ + Storage +
+ + {vm.maxdisk ? formatBytes(vm.maxdisk) : 'N/A'} + +
+
+ +
+
+
+ + )) + ) : ( +
+ + +

No virtual machines found

+
+
+
+ )} +
+

+ Click on any virtual machine to open it in Proxmox +

+ + + + {/* Description */} + + + Description + + + + + {pod.template?.description ? ( + pod.template.description.length > 1000 ? ( + +
+
-

- {vm.status === 'running' ? ( - - ) : ( - vm.status - )} -

-
- - )) + ) : ( -
-

No virtual machines found

+
+ +
+ ) + ) : ( +
+ No description available
- )} -
-
-
- - {/* Description */} -
-

Description

- - - -
-
+ )} + + + + + {/* Footer section */} -
-
-
- +
+
+
+ +
+
+

Authors

{pod.template?.authors ? ( - +

{pod.template.authors} - +

) : ( - No authors specified +

No authors specified

)}
- -
+ + +
) })}
diff --git a/my-app/app/pods/deployed/page.tsx b/my-app/app/pods/deployed/page.tsx index 053002c..687354a 100644 --- a/my-app/app/pods/deployed/page.tsx +++ b/my-app/app/pods/deployed/page.tsx @@ -44,8 +44,8 @@ export default function Page() { const pageHeader = (
-

Deployed Pods

-

+

Deployed Pods

+

Browse and manage your deployed pod instances.

diff --git a/my-app/app/pods/templates/page.tsx b/my-app/app/pods/templates/page.tsx index 80b3928..7206a47 100644 --- a/my-app/app/pods/templates/page.tsx +++ b/my-app/app/pods/templates/page.tsx @@ -19,8 +19,8 @@ export default function Page() { const pageHeader = (
-

Pod Templates

-

+

Pod Templates

+

Browse and deploy your own instance of our curated interactive pod environments.

diff --git a/my-app/app/pods/templates/pod-templates-cards.tsx b/my-app/app/pods/templates/pod-templates-cards.tsx index ca822e1..7b31bb0 100644 --- a/my-app/app/pods/templates/pod-templates-cards.tsx +++ b/my-app/app/pods/templates/pod-templates-cards.tsx @@ -1,9 +1,9 @@ "use client" -import { MarkdownRenderer } from "@/components/ui/markdown-renderer" -import { RocketIcon, ServerIcon } from "lucide-react" +import { Calendar, RocketIcon, ServerIcon, User } from "lucide-react" import Image from "next/image" import { PodTemplate } from "@/lib/types" +import { formatPodName } from "@/lib/utils" type SectionCardsProps = { pods: PodTemplate[] @@ -34,7 +34,7 @@ export function SectionCards({ pods, onDeploy }: SectionCardsProps) { function TemplateCard({ template, onDeploy }: { template: PodTemplate; onDeploy: (template: PodTemplate) => void }) { return (
onDeploy(template)} > {/* Pod Image */} @@ -56,40 +56,40 @@ function TemplateCard({ template, onDeploy }: { template: PodTemplate; onDeploy: {/* Pod Content */}
- {/* Pod Name */} -

{template.name.replaceAll('_', ' ')}

- - {/* Pod Description */} -
-
- -
-
+ {/* Date, title, & authors */} +
+ {template.created_at && ( +

+ + {new Date(template.created_at).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })} +

+ )} +

+ {formatPodName(template.name)} +

+ {template.authors && ( +
+ + {template.authors} +
+ )}
- {/* Authors */} - {template.authors && ( -
- Authors: - {template.authors} -
- )} - {/* Pod Stats */}
-
+
{/* VMs */}
-
{template.vm_count}
+
{template.vm_count}
- + {(template.vm_count || 0) === 1 ? "VM" : "VMs"}
@@ -102,10 +102,10 @@ function TemplateCard({ template, onDeploy }: { template: PodTemplate; onDeploy: {/* Deployments */}
-
{template.deployments}
+
{template.deployments}
- + {template.deployments === 1 ? "Deployment" : "Deployments"}
diff --git a/my-app/app/register/page.tsx b/my-app/app/register/page.tsx deleted file mode 100644 index 9de976a..0000000 --- a/my-app/app/register/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client" - -import { RegisterForm } from "@/app/register/register-form" -import { Button } from "@/components/ui/button" -import Link from "next/link" -import Image from "next/image" - -export default function RegisterPage() { - return ( -
-
- -
-
- -
-
-
-
- Image -
-
-

Kamino

-

- This application empowers you to rapidly spin up and delete Pods of virtual machines hosted on the - - . -

-
-
-
-
- ) -} diff --git a/my-app/app/register/register-form.tsx b/my-app/app/register/register-form.tsx deleted file mode 100644 index 9770203..0000000 --- a/my-app/app/register/register-form.tsx +++ /dev/null @@ -1,249 +0,0 @@ -"use client" - -import { useState } from "react" -import { cn, validateUsername, filterUsernameInput, UsernameValidationResult, validatePassword, filterPasswordInput, PasswordValidationResult } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Alert, AlertDescription } from "@/components/ui/alert" -import { AlertCircle } from "lucide-react" -import { useRouter } from "next/navigation" -import { registerUser } from "@/lib/api" -import Link from "next/link" - -interface FormData { - username: string - password: string - confirmPassword: string -} - -export function RegisterForm({ - className, - ...props -}: React.ComponentProps<"form">) { - const [formData, setFormData] = useState({ - username: '', - password: '', - confirmPassword: '' - }) - const [usernameValidation, setUsernameValidation] = useState({ isValid: true, errors: [] }) - const [passwordValidation, setPasswordValidation] = useState({ isValid: true, errors: [] }) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState("") - const [success, setSuccess] = useState(false) - const router = useRouter() - - // Handle username input changes with validation - const handleUsernameChange = (value: string) => { - const filtered = filterUsernameInput(value) - setFormData((prev) => ({ - ...prev, - username: filtered, - })) - setUsernameValidation(validateUsername(filtered)) - } - - // Handle password input changes with validation - const handlePasswordChange = (value: string) => { - const filtered = filterPasswordInput(value) - setFormData((prev) => ({ - ...prev, - password: filtered, - })) - setPasswordValidation(validatePassword(filtered)) - } - - // Handle other input changes - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target - if (name === 'password') { - handlePasswordChange(value) - } else if (name === 'confirmPassword') { - const filtered = filterPasswordInput(value) - setFormData((prev) => ({ - ...prev, - [name]: filtered, - })) - } else { - setFormData((prev) => ({ - ...prev, - [name]: value, - })) - } - } - - // Validate form data - const validateForm = (): string | null => { - const usernameValidationResult = validateUsername(formData.username) - if (!usernameValidationResult.isValid) { - return usernameValidationResult.errors[0] - } - if (formData.username.length < 3) { - return 'Username must be at least 3 characters long' - } - const passwordValidationResult = validatePassword(formData.password) - if (!passwordValidationResult.isValid) { - return passwordValidationResult.errors[0] - } - if (formData.password.length < 8) { - return 'Password must be at least 8 characters long' - } - if (formData.password !== formData.confirmPassword) { - return 'Passwords do not match' - } - return null - } - - // Handle form submission - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setIsLoading(true) - setError("") - setSuccess(false) - - // Validate form - const validationError = validateForm() - if (validationError) { - setError(validationError) - setIsLoading(false) - return - } - - try { - // Call the register API function - await registerUser(formData.username, formData.password) - - setSuccess(true) - setError('') - - // Redirect to login page after successful registration - setTimeout(() => { - router.push('/login') - }, 2000) - - } catch (error) { - console.error('Registration error:', error) - - if (error instanceof Error) { - if (error.message.includes('409')) { - setError('Username already exists.') - } else if (error.message.includes('400')) { - setError('Invalid registration data. Please check your inputs.') - } else { - setError('Registration failed. Please try again later.') - } - } else { - setError('Registration failed. Please try again later.') - } - } - - setIsLoading(false) - } - - if (success) { - return ( -
-
-
- - - -
-

Registration Successful!

-

- Your account has been created successfully. Redirecting to login page... -

-
-
- ) - } - - return ( -
-
-

Create Account

-
-
-
- - handleUsernameChange(e.target.value)} - required - className={`focus:border-kamino-green focus:ring-kamino-green ${!usernameValidation.isValid ? "border-destructive" : ""}`} - /> - {!usernameValidation.isValid && ( - - - - {usernameValidation.errors[0]} - - - )} -
- Max 20 characters. Only letters and numbers allowed. -
-
-
- - - {!passwordValidation.isValid && ( - - - - {passwordValidation.errors[0]} - - - )} -
- At least 8 characters, must contain at least one letter and one number. -
-
-
- - -
- {error && ( -
- {error} -
- )} - -
- Already have an account?{" "} - - Sign in - -
-
-
- ) -} diff --git a/my-app/components/footer.tsx b/my-app/components/footer.tsx index d445165..45fb31d 100644 --- a/my-app/components/footer.tsx +++ b/my-app/components/footer.tsx @@ -1,18 +1,8 @@ import { MapPin } from 'lucide-react'; import Image from 'next/image'; +import Link from 'next/link'; const data = { - facebookLink: 'https://facebook.com/mvpblocks', - instaLink: 'https://instagram.com/mvpblocks', - twitterLink: 'https://twitter.com/mvpblocks', - githubLink: 'https://github.com/mvpblocks', - dribbbleLink: 'https://dribbble.com/mvpblocks', - services: { - webdev: '/web-development', - webdesign: '/web-design', - marketing: '/marketing', - googleads: '/google-ads', - }, about: { sdc: 'https://www.cpp.edu/cba/digital-innovation/about-us.shtml', programs: 'https://www.cpp.edu/cba/digital-innovation/what-we-do/programs.shtml', @@ -20,8 +10,8 @@ const data = { news: 'https://www.cpp.edu/cba/digital-innovation/news-and-events2.shtml', }, help: { - kamino: '/info', - cloning: '/info#cloning', + kamino: '/info#what-is-kamino', + features: '/info#features', infrastructure: '/info#infrastructure', }, location: { @@ -35,10 +25,6 @@ const data = { }, }; -// const socialLinks = [ -// { icon: Globe, label: 'Website', href: 'https://www.cpp.edu/cba/digital-innovation/index.shtml' }, -// ]; - const aboutLinks = [ { text: 'Student Data Center', href: data.about.sdc }, { text: 'Programs', href: data.about.programs }, @@ -47,7 +33,7 @@ const aboutLinks = [ const helpfulLinks = [ { text: 'What is Kamino?', href: data.help.kamino }, - { text: 'Cloning', href: data.help.cloning }, + { text: 'Features', href: data.help.features }, { text: 'Infrastructure', href: data.help.infrastructure }, ]; @@ -77,20 +63,6 @@ export default function Footer4Col() {

{data.company.description}

- - {/*
    - {socialLinks.map(({ icon: Icon, label, href }) => ( -
  • - - {label} - - -
  • - ))} -
*/}
@@ -112,37 +84,17 @@ export default function Footer4Col() {
- {/*
-

Our Services

-
    - {serviceLinks.map(({ text, href }) => ( -
  • - - {text} - -
  • - ))} -
-
*/} -

Helpful Links

diff --git a/my-app/components/pod-deploy-dialog.tsx b/my-app/components/pod-deploy-dialog.tsx index 8cee234..14af0c9 100644 --- a/my-app/components/pod-deploy-dialog.tsx +++ b/my-app/components/pod-deploy-dialog.tsx @@ -16,15 +16,23 @@ import { DialogContent, DialogTitle, } from "@/components/ui/dialog" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion" import { Button } from "@/components/ui/button" import { ScrollArea } from "@/components/ui/scroll-area" import { MarkdownRenderer } from "@/components/ui/markdown-renderer" import { Progress } from "@/components/ui/progress" -import { CalendarIcon, Rocket, Server as ServerIcon, Rocket as RocketIcon } from "lucide-react" +import { CalendarIcon, Rocket, Server as ServerIcon, Rocket as RocketIcon, User } from "lucide-react" import Image from "next/image" import { VisuallyHidden } from "radix-ui" import { handleUserPodDeployment } from "@/lib/admin-operations" import { PodTemplate } from "@/lib/types" +import { formatPodName } from "@/lib/utils" +import { Separator } from "./ui/separator" interface PodDeployDialogProps { isOpen: boolean @@ -110,12 +118,12 @@ export function PodDeployDialog({ isOpen, onClose, selectedPod }: PodDeployDialo Deploy Pod Template - + {selectedPod && ( -
-
+
+
{/* Top section with image, date, and title */} -
+
{/* Square image */}
@@ -130,33 +138,66 @@ export function PodDeployDialog({ isOpen, onClose, selectedPod }: PodDeployDialo
- {/* Date and title */} + {/* Date, title, & authors */}
{selectedPod.created_at && (

- - {new Date(selectedPod.created_at).toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - })} + + {new Date(selectedPod.created_at).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })}

)} -

- {selectedPod.name.replaceAll('_', ' ')} +

+ {formatPodName(selectedPod.name)}

+ {selectedPod.authors && ( +
+ + {selectedPod.authors} +
+ )}
- {/* Scrollable description */} -
- - - -
+ {/* Description Accordion */} + + + + Description + + +
+
+ {selectedPod.description ? ( + selectedPod.description.length > 1000 ? ( + +
+ +
+
+ ) : ( +
+ +
+ ) + ) : ( +
+ No description available +
+ )} +
+
+
+
+
{/* Pod Stats */}
@@ -176,8 +217,8 @@ export function PodDeployDialog({ isOpen, onClose, selectedPod }: PodDeployDialo
{/* Separator */} -
- + + {/* Deployments */}
@@ -194,17 +235,16 @@ export function PodDeployDialog({ isOpen, onClose, selectedPod }: PodDeployDialo
- {/* Bottom buttons */} -
- -
+ {/* Bottom buttons - fixed at bottom */} + +
)} diff --git a/my-app/components/shared/deploy-pod-dialog.tsx b/my-app/components/shared/deploy-pod-dialog.tsx index a34fd3f..a4b9386 100644 --- a/my-app/components/shared/deploy-pod-dialog.tsx +++ b/my-app/components/shared/deploy-pod-dialog.tsx @@ -291,33 +291,6 @@ export function DeployPodDialog({ onPodDeployed, trigger }: DeployPodDialogProps ))} - - {/* Template Details */} - {selectedTemplate && (() => { - const template = templates.find(t => t.name === selectedTemplate) - return template && ( -
-

Template Details

- {template.description && ( -

- {template.description} -

- )} - {template.authors && ( -

- Authors: - {template.authors} -

- )} - {template.vm_count && ( -

- VM Count: - {template.vm_count} -

- )} -
- ) - })()}
) diff --git a/my-app/components/ui/markdown-renderer.tsx b/my-app/components/ui/markdown-renderer.tsx index 22cc322..1820864 100644 --- a/my-app/components/ui/markdown-renderer.tsx +++ b/my-app/components/ui/markdown-renderer.tsx @@ -25,29 +25,41 @@ export function MarkdownRenderer({ } return ( -
+

{children}

, - h1: ({ children }) =>

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - li: ({ children }) =>
  • {children}
  • , + p: ({ children }) =>

    {children}

    , + h1: ({ children }) =>

    {children}

    , + h2: ({ children }) =>

    {children}

    , + h3: ({ children }) =>

    {children}

    , + ul: ({ children }) => ( +
      + {children} +
    + ), + ol: ({ children }) => ( +
      + {children} +
    + ), + li: ({ children }) => ( +
  • + {children} +
  • + ), code: ({ children, className }) => { const isInline = !className; return isInline ? ( {children} ) : ( -
    +              
                     {children}
                   
    ); }, blockquote: ({ children }) => ( -
    +
    {children}
    ), @@ -64,7 +76,7 @@ export function MarkdownRenderer({ src={src} alt={alt || ''} unoptimized - className="max-w-full max-h-96 object-contain rounded-md mb-2" + className="max-w-full max-h-96 object-contain rounded-md mb-4" width={384} height={384} /> diff --git a/my-app/components/ui/timeline.tsx b/my-app/components/ui/timeline.tsx new file mode 100644 index 0000000..e62b30f --- /dev/null +++ b/my-app/components/ui/timeline.tsx @@ -0,0 +1,205 @@ +"use client" + +import * as React from "react" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +// Types +type TimelineContextValue = { + activeStep: number + setActiveStep: (step: number) => void +} + +// Context +const TimelineContext = React.createContext( + undefined +) + +const useTimeline = () => { + const context = React.useContext(TimelineContext) + if (!context) { + throw new Error("useTimeline must be used within a Timeline") + } + return context +} + +// Components +interface TimelineProps extends React.HTMLAttributes { + defaultValue?: number + value?: number + onValueChange?: (value: number) => void + orientation?: "horizontal" | "vertical" +} + +function Timeline({ + defaultValue = 1, + value, + onValueChange, + orientation = "vertical", + className, + ...props +}: TimelineProps) { + const [activeStep, setInternalStep] = React.useState(defaultValue) + + const setActiveStep = React.useCallback( + (step: number) => { + if (value === undefined) { + setInternalStep(step) + } + onValueChange?.(step) + }, + [value, onValueChange] + ) + + const currentStep = value ?? activeStep + + return ( + +
    + + ) +} + +// TimelineContent +function TimelineContent({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
    + ) +} + +// TimelineDate +interface TimelineDateProps extends React.HTMLAttributes { + asChild?: boolean +} + +function TimelineDate({ + asChild = false, + className, + ...props +}: TimelineDateProps) { + const Comp = asChild ? Slot.Root : "time" + + return ( + + ) +} + +// TimelineHeader +function TimelineHeader({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
    + ) +} + +// TimelineIndicator +function TimelineIndicator({ + className, + children, + ...props +}: React.HTMLAttributes) { + return ( + + ) +} + +// TimelineItem +interface TimelineItemProps extends React.HTMLAttributes { + step: number +} + +function TimelineItem({ step, className, ...props }: TimelineItemProps) { + const { activeStep } = useTimeline() + + return ( +
    + ) +} + +// TimelineSeparator +function TimelineSeparator({ + className, + ...props +}: React.HTMLAttributes) { + return ( +