Initial commit

This commit is contained in:
2026-02-04 00:17:30 +00:00
commit 890e52af8c
127 changed files with 9682 additions and 0 deletions

13
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
frontend/src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

230
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,230 @@
import { toast } from "svelte-sonner";
import { get } from 'svelte/store';
import { token } from './auth';
const API_BASE = "http://localhost:8080";
export interface DeployResponse {
status: string;
app_name: string;
port: number;
url: string;
message: string;
}
export interface AuthResponse {
token: string;
user: any;
}
export interface EnvVar {
key: string;
value: string;
}
export interface Project {
ID: number;
name: string;
repo_url: string;
port: number;
deployments: any[];
env_vars: EnvVar[];
webhook_secret: string;
git_token?: string;
runtime?: string;
}
export async function getProject(id: string): Promise<Project | null> {
try {
return await fetchWithAuth(`/api/projects/${id}`);
} catch (e: any) {
console.error(e);
return null;
}
}
async function fetchWithAuth(url: string, options: RequestInit = {}) {
const t = get(token);
const headers = {
"Content-Type": "application/json",
...options.headers,
...(t ? { Authorization: `Bearer ${t}` } : {}),
};
const res = await fetch(`${API_BASE}${url}`, { ...options, headers });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Request failed");
}
return res.json();
}
export async function loginUser(email: string, password: string): Promise<AuthResponse | null> {
try {
return await fetchWithAuth("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
} catch (e: any) {
toast.error(e.message);
return null;
}
}
export async function registerUser(email: string, password: string, name: string): Promise<AuthResponse | null> {
try {
return await fetchWithAuth("/auth/register", {
method: "POST",
body: JSON.stringify({ email, password, name }),
});
} catch (e: any) {
toast.error(e.message);
return null;
}
}
export async function createProject(
repo: string,
name: string,
port?: number,
envVars?: Record<string, string>,
gitToken?: string,
buildCommand?: string,
startCommand?: string,
installCommand?: string,
runtime?: string
): Promise<Project | null> {
try {
return await fetchWithAuth("/api/projects", {
method: "POST",
body: JSON.stringify({
repo,
name,
port,
env_vars: envVars,
git_token: gitToken,
build_command: buildCommand,
start_command: startCommand,
install_command: installCommand,
runtime: runtime,
}),
});
} catch (e: any) {
toast.error(e.message);
return null;
}
}
export async function updateProjectEnv(id: string, envVars: Record<string, string>): Promise<boolean> {
try {
await fetchWithAuth(`/api/projects/${id}/env`, {
method: "PUT",
body: JSON.stringify({ env_vars: envVars }),
});
return true;
} catch (e: any) {
toast.error(e.message);
return false;
}
}
export async function redeployProject(id: string): Promise<boolean> {
try {
await fetchWithAuth(`/api/projects/${id}/redeploy`, {
method: "POST",
});
return true;
} catch (e: any) {
toast.error(e.message);
return false;
}
}
export async function listProjects(): Promise<Project[] | null> {
try {
return await fetchWithAuth("/api/projects");
} catch (e: any) {
console.error(e);
return null;
}
}
export async function deployApp(repo: string, name: string, port?: number): Promise<DeployResponse | null> {
return createProject(repo, name, port) as any;
}
export async function listActivity(): Promise<any[] | null> {
try {
return await fetchWithAuth("/api/activity");
} catch (e: any) {
console.error(e);
return null;
}
}
export async function updateProfile(name: string, email: string) {
try {
return await fetchWithAuth("/api/user/profile", {
method: "PUT",
body: JSON.stringify({ name, email }),
});
} catch (e: any) {
toast.error(e.message);
return null;
}
}
export async function getSystemStatus() {
try {
const res = await fetch(`${API_BASE}/api/system/status`);
if (res.ok) {
return await res.json();
}
return null;
} catch (e: any) {
console.error(e);
return null;
}
}
export async function updatePassword(oldPassword: string, newPassword: string) {
try {
return await fetchWithAuth("/api/user/password", {
method: "PUT",
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
});
} catch (e: any) {
toast.error(e.message);
return null;
}
}
export async function getStorageStats() {
try {
return await fetchWithAuth("/api/storage/stats");
} catch (e: any) {
console.error(e);
return null;
}
}
export async function listDatabases() {
try {
return await fetchWithAuth("/api/storage/databases");
} catch (e: any) {
console.error(e);
return [];
}
}
export async function createDatabase(name: string, type: string = "sqlite") {
try {
return await fetchWithAuth("/api/storage/databases", {
method: "POST",
body: JSON.stringify({ name, type }),
});
} catch (e: any) {
toast.error(e.message);
return null;
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

33
frontend/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,33 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export interface User {
ID: number;
email: string;
name: string;
avatar: string;
}
const storedUser = browser ? localStorage.getItem('user') : null;
const storedToken = browser ? localStorage.getItem('token') : null;
export const user = writable<User | null>(storedUser ? JSON.parse(storedUser) : null);
export const token = writable<string | null>(storedToken);
export function login(userData: User, apiToken: string) {
user.set(userData);
token.set(apiToken);
if (browser) {
localStorage.setItem('user', JSON.stringify(userData));
localStorage.setItem('token', apiToken);
}
}
export function logout() {
user.set(null);
token.set(null);
if (browser) {
localStorage.removeItem('user');
localStorage.removeItem('token');
}
}

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import {
Github,
Twitter,
CheckCircle2,
AlertCircle,
Terminal,
} from "@lucide/svelte";
import { onMount } from "svelte";
import { getSystemStatus } from "$lib/api";
let status = $state("Checking...");
let version = $state("");
let isNormal = $state(true);
onMount(async () => {
const res = await getSystemStatus();
if (res) {
status = res.status;
version = res.version;
isNormal = true;
} else {
status = "System Unavailable";
isNormal = false;
}
});
</script>
<footer
class="border-t py-6 md:py-0 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60"
>
<div
class="container flex flex-col items-center justify-center gap-6 md:h-16 md:flex-row px-4"
>
<div class="flex flex-col items-center gap-6 md:flex-row">
<a href="/" class="flex items-center gap-2 text-foreground font-semibold">
<div class="bg-foreground text-background rounded-full p-1">
<Terminal class="h-3 w-3" />
</div>
</a>
<nav
class="flex flex-wrap justify-center gap-4 text-sm text-muted-foreground"
>
<a href="/" class="hover:text-foreground transition-colors">Home</a>
<a href="/" class="hover:text-foreground transition-colors">Docs</a>
<a
href="https://github.com/SirBlobby/Clickploy"
target="_blank"
rel="noreferrer"
class="hover:text-foreground transition-colors">GitHub</a
>
<a href="/" class="hover:text-foreground transition-colors">Support</a>
<a href="/" class="hover:text-foreground transition-colors">Legal</a>
</nav>
</div>
<div class="h-4 w-px bg-border hidden md:block"></div>
<div class="flex items-center gap-2 text-sm font-medium">
<div
class={`h-2.5 w-2.5 rounded-full ${isNormal ? "bg-blue-500" : "bg-red-500"} animate-pulse`}
></div>
<span class={isNormal ? "text-blue-500" : "text-red-500"}>
{isNormal ? "All systems normal." : status}
</span>
</div>
</div>
</footer>

View File

@@ -0,0 +1,183 @@
<script lang="ts">
import { user, logout } from "$lib/auth";
import { Button } from "$lib/components/ui/button";
import {
Terminal,
Settings,
LogOut,
Activity,
LayoutDashboard,
Menu,
X,
Rocket,
Network,
Database,
} from "@lucide/svelte";
import { page } from "$app/stores";
import * as Sheet from "$lib/components/ui/sheet";
let mobileOpen = $state(false);
function isActive(path: string) {
return $page.url.pathname === path ? "bg-secondary" : "hover:bg-accent/50";
}
</script>
<header
class="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur-sm"
>
<div class="container mx-auto flex h-14 items-center justify-between px-4">
<div class="flex items-center gap-6">
<a href="/" class="flex items-center gap-2 font-bold text-xl">
<Terminal class="h-6 w-6" />
<span>Clickploy</span>
</a>
{#if $user}
<nav class="hidden md:flex items-center gap-2">
<Button variant="ghost" size="sm" href="/" class={isActive("/")}>
<LayoutDashboard class="mr-2 h-4 w-4" /> Overview
</Button>
<Button
variant="ghost"
size="sm"
href="/deployments"
class={isActive("/deployments")}
>
<Rocket class="mr-2 h-4 w-4" /> Deployments
</Button>
<Button
variant="ghost"
size="sm"
href="/network"
class={isActive("/network")}
>
<Network class="mr-2 h-4 w-4" /> Network
</Button>
<Button
variant="ghost"
size="sm"
href="/activity"
class={isActive("/activity")}
>
<Activity class="mr-2 h-4 w-4" /> Activity
</Button>
<Button
variant="ghost"
size="sm"
href="/storage"
class={isActive("/storage")}
>
<Database class="mr-2 h-4 w-4" /> Storage
</Button>
<Button
variant="ghost"
size="sm"
href="/settings"
class={isActive("/settings")}
>
<Settings class="mr-2 h-4 w-4" /> Settings
</Button>
</nav>
{/if}
</div>
{#if $user}
<div class="flex items-center gap-4">
<div class="hidden md:flex items-center gap-2">
<div
class="h-8 w-8 rounded-full bg-linear-to-tr from-primary to-purple-500"
></div>
<span class="text-sm font-medium">{$user.name}</span>
</div>
<Button
variant="ghost"
size="icon"
onclick={logout}
title="Log out"
class="hidden md:flex"
>
<LogOut class="h-4 w-4" />
</Button>
<div class="md:hidden">
<Sheet.Root bind:open={mobileOpen}>
<Sheet.Trigger>
<Button variant="ghost" size="icon">
<Menu class="h-5 w-5" />
</Button>
</Sheet.Trigger>
<Sheet.Content side="right">
<Sheet.Header>
<Sheet.Title>Menu</Sheet.Title>
</Sheet.Header>
<div class="grid gap-4 py-4">
<a
href="/"
class="flex items-center gap-2 py-2 text-lg font-medium"
onclick={() => (mobileOpen = false)}
>
<LayoutDashboard class="h-5 w-5" /> Overview
</a>
<a
href="/deployments"
class="flex items-center gap-2 py-2 text-lg font-medium"
onclick={() => (mobileOpen = false)}
>
<Rocket class="h-5 w-5" /> Deployments
</a>
<a
href="/network"
class="flex items-center gap-2 py-2 text-lg font-medium"
onclick={() => (mobileOpen = false)}
>
<Network class="h-5 w-5" /> Network
</a>
<a
href="/activity"
class="flex items-center gap-2 py-2 text-lg font-medium"
onclick={() => (mobileOpen = false)}
>
<Activity class="h-5 w-5" /> Activity
</a>
<a
href="/storage"
class="flex items-center gap-2 py-2 text-lg font-medium"
onclick={() => (mobileOpen = false)}
>
<Database class="h-5 w-5" /> Storage
</a>
<a
href="/settings"
class="flex items-center gap-2 py-2 text-lg font-medium"
onclick={() => (mobileOpen = false)}
>
<Settings class="h-5 w-5" /> Settings
</a>
<div class="border-t my-2"></div>
<div class="flex items-center gap-2 py-2">
<div
class="h-6 w-6 rounded-full bg-linear-to-tr from-primary to-purple-500"
></div>
<span class="font-medium">{$user.name}</span>
</div>
<Button
variant="ghost"
class="justify-start gap-2 px-0"
onclick={logout}
>
<LogOut class="h-5 w-5" /> Log out
</Button>
</div>
</Sheet.Content>
</Sheet.Root>
</div>
</div>
{:else}
<div class="flex gap-2">
<Button variant="ghost" size="sm" href="/login">Login</Button>
<Button size="sm" href="/register">Get Started</Button>
</div>
{/if}
</div>
</header>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,44 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,14 @@
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
export { alertVariants, type AlertVariant } from "./alert.svelte";
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};

View File

@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("leading-none font-semibold", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
</script>
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
</script>
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
open = $bindable(false),
...restProps
}: CollapsiblePrimitive.RootProps = $props();
</script>
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />

View File

@@ -0,0 +1,13 @@
import Root from "./collapsible.svelte";
import Trigger from "./collapsible-trigger.svelte";
import Content from "./collapsible-content.svelte";
export {
Root,
Content,
Trigger,
//
Root as Collapsible,
Content as CollapsibleContent,
Trigger as CollapsibleTrigger,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-lg leading-none font-semibold", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,34 @@
import Root from "./dialog.svelte";
import Portal from "./dialog-portal.svelte";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};

View File

@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
import Root from "./progress.svelte";
export {
Root,
//
Root as Progress,
};

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { Progress as ProgressPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
max = 100,
value,
...restProps
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> = $props();
</script>
<ProgressPrimitive.Root
bind:ref
data-slot="progress"
class={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
{value}
{max}
{...restProps}
>
<div
data-slot="progress-indicator"
class="bg-primary h-full w-full flex-1 transition-all"
style="transform: translateX(-{100 - (100 * (value ?? 0)) / (max ?? 1)}%)"
></div>
</ProgressPrimitive.Root>

View File

@@ -0,0 +1,37 @@
import Root from "./select.svelte";
import Group from "./select-group.svelte";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
import Portal from "./select-portal.svelte";
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Portal as SelectPortal,
};

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectPortal from "./select-portal.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
/>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />

View File

@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:min-h-full data-[orientation=vertical]:w-px",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,34 @@
import Root from "./sheet.svelte";
import Portal from "./sheet-portal.svelte";
import Trigger from "./sheet-trigger.svelte";
import Close from "./sheet-close.svelte";
import Overlay from "./sheet-overlay.svelte";
import Content from "./sheet-content.svelte";
import Header from "./sheet-header.svelte";
import Footer from "./sheet-footer.svelte";
import Title from "./sheet-title.svelte";
import Description from "./sheet-description.svelte";
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
</script>
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />

View File

@@ -0,0 +1,60 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const sheetVariants = tv({
base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
variants: {
side: {
top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
left: "data-[state=closed]:slide-out-to-start data-[state=open]:slide-in-from-start inset-y-0 start-0 h-full w-3/4 border-e sm:max-w-sm",
right: "data-[state=closed]:slide-out-to-end data-[state=open]:slide-in-from-end inset-y-0 end-0 h-full w-3/4 border-s sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
});
export type Side = VariantProps<typeof sheetVariants>["side"];
</script>
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import SheetPortal from "./sheet-portal.svelte";
import SheetOverlay from "./sheet-overlay.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
side = "right",
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SheetPortal>>;
side?: Side;
children: Snippet;
} = $props();
</script>
<SheetPortal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content
bind:ref
data-slot="sheet-content"
class={cn(sheetVariants({ side }), className)}
{...restProps}
>
{@render children?.()}
<SheetPrimitive.Close
class="ring-offset-background focus-visible:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
>
<XIcon class="size-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script>
<SheetPrimitive.Description
bind:ref
data-slot="sheet-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-footer"
class={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-header"
class={cn("flex flex-col gap-1.5 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
</script>
<SheetPrimitive.Overlay
bind:ref
data-slot="sheet-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ...restProps }: SheetPrimitive.PortalProps = $props();
</script>
<SheetPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.TitleProps = $props();
</script>
<SheetPrimitive.Title
bind:ref
data-slot="sheet-title"
class={cn("text-foreground font-semibold", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
</script>
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: SheetPrimitive.RootProps = $props();
</script>
<SheetPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,28 @@
import Root from "./table.svelte";
import Body from "./table-body.svelte";
import Caption from "./table-caption.svelte";
import Cell from "./table-cell.svelte";
import Footer from "./table-footer.svelte";
import Head from "./table-head.svelte";
import Header from "./table-header.svelte";
import Row from "./table-row.svelte";
export {
Root,
Body,
Caption,
Cell,
Footer,
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,
Cell as TableCell,
Footer as TableFooter,
Head as TableHead,
Header as TableHeader,
Row as TableRow,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tbody
bind:this={ref}
data-slot="table-body"
class={cn("[&_tr:last-child]:border-0", className)}
{...restProps}
>
{@render children?.()}
</tbody>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<caption
bind:this={ref}
data-slot="table-caption"
class={cn("text-muted-foreground mt-4 text-sm", className)}
{...restProps}
>
{@render children?.()}
</caption>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLTdAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTdAttributes> = $props();
</script>
<td
bind:this={ref}
data-slot="table-cell"
class={cn(
"bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0",
className
)}
{...restProps}
>
{@render children?.()}
</td>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tfoot
bind:this={ref}
data-slot="table-footer"
class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...restProps}
>
{@render children?.()}
</tfoot>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLThAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLThAttributes> = $props();
</script>
<th
bind:this={ref}
data-slot="table-head"
class={cn(
"text-foreground h-10 bg-clip-padding px-2 text-start align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pe-0",
className
)}
{...restProps}
>
{@render children?.()}
</th>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<thead
bind:this={ref}
data-slot="table-header"
class={cn("[&_tr]:border-b", className)}
{...restProps}
>
{@render children?.()}
</thead>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
</script>
<tr
bind:this={ref}
data-slot="table-row"
class={cn(
"hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...restProps}
>
{@render children?.()}
</tr>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLTableAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTableAttributes> = $props();
</script>
<div data-slot="table-container" class="relative w-full overflow-x-auto">
<table
bind:this={ref}
data-slot="table"
class={cn("w-full caption-bottom text-sm", className)}
{...restProps}
>
{@render children?.()}
</table>
</div>

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

13
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,13 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import { loginUser } from "$lib/api";
import { login } from "$lib/auth";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { goto } from "$app/navigation";
import { Loader2 } from "@lucide/svelte";
let email = $state("");
let password = $state("");
let loading = $state(false);
async function handleSubmit() {
if (!email || !password) return;
loading = true;
const res = await loginUser(email, password);
if (res) {
login(res.user, res.token);
goto("/");
}
loading = false;
}
</script>
<div class="flex h-screen items-center justify-center bg-muted/50">
<Card class="w-[350px]">
<CardHeader>
<CardTitle>Sign In</CardTitle>
<CardDescription>Enter your email to access your account</CardDescription>
</CardHeader>
<CardContent>
<div class="grid gap-4">
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
bind:value={email}
/>
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input id="password" type="password" bind:value={password} />
</div>
</div>
</CardContent>
<CardFooter class="flex flex-col gap-4">
<Button class="w-full" onclick={handleSubmit} disabled={loading}>
{#if loading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Sign In
</Button>
<div class="text-center text-sm text-muted-foreground">
Don't have an account? <a
href="/register"
class="underline hover:text-primary">Sign up</a
>
</div>
</CardFooter>
</Card>
</div>

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import { registerUser } from "$lib/api";
import { login } from "$lib/auth";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { goto } from "$app/navigation";
import { Loader2 } from "@lucide/svelte";
let name = $state("");
let email = $state("");
let password = $state("");
let loading = $state(false);
async function handleSubmit() {
if (!email || !password || !name) return;
loading = true;
const res = await registerUser(email, password, name);
if (res) {
login(res.user, res.token);
goto("/");
}
loading = false;
}
</script>
<div class="flex h-screen items-center justify-center bg-muted/50">
<Card class="w-[350px]">
<CardHeader>
<CardTitle>Create Account</CardTitle>
<CardDescription>Enter your email to create an account</CardDescription>
</CardHeader>
<CardContent>
<div class="grid gap-4">
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" placeholder="John Doe" bind:value={name} />
</div>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
bind:value={email}
/>
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input id="password" type="password" bind:value={password} />
</div>
</div>
</CardContent>
<CardFooter class="flex flex-col gap-4">
<Button class="w-full" onclick={handleSubmit} disabled={loading}>
{#if loading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Sign Up
</Button>
<div class="text-center text-sm text-muted-foreground">
Already have an account? <a
href="/login"
class="underline hover:text-primary">Sign in</a
>
</div>
</CardFooter>
</Card>
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import "./layout.css";
import favicon from "$lib/assets/favicon.svg";
import { Toaster } from "svelte-sonner";
import Navbar from "$lib/components/Navbar.svelte";
import Footer from "$lib/components/Footer.svelte";
let { children } = $props();
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<Navbar />
<Toaster position="top-right" richColors />
<div class="min-h-screen bg-background text-foreground flex flex-col">
<div class="flex-1">
{@render children()}
</div>
<Footer />
</div>

View File

@@ -0,0 +1,491 @@
<script lang="ts">
import { user, logout } from "$lib/auth";
import { listProjects, createProject, type Project } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "$lib/components/ui/card";
import * as Dialog from "$lib/components/ui/dialog";
import {
Loader2,
Plus,
Github,
LogOut,
Terminal,
Activity,
Settings,
ChevronsUpDown,
ExternalLink,
} from "@lucide/svelte";
import * as Collapsible from "$lib/components/ui/collapsible";
import * as Select from "$lib/components/ui/select";
import { onMount } from "svelte";
let projects: Project[] = $state([]);
let loading = $state(true);
let newProjectOpen = $state(false);
let repo = $state("https://github.com/heroku/node-js-sample");
let name = $state("");
let port = $state("");
let gitToken = $state("");
let buildCommand = $state("");
let startCommand = $state("");
let installCommand = $state("");
let runtime = $state("nodejs");
let envVars = $state([{ key: "", value: "" }]);
let deploying = $state(false);
let createdProject = $state<any>(null);
onMount(async () => {
if ($user) {
const res = await listProjects();
if (res) projects = res;
}
loading = false;
});
function addEnvVar() {
envVars = [...envVars, { key: "", value: "" }];
}
function removeEnvVar(index: number) {
envVars = envVars.filter((_, i) => i !== index);
}
async function handleDeploy() {
if (!repo || !name) return;
deploying = true;
const p = port ? parseInt(port) : undefined;
const envMap: Record<string, string> = {};
for (const e of envVars) {
if (e.key) envMap[e.key] = e.value;
}
const res = await createProject(
repo,
name,
p,
envMap,
gitToken,
buildCommand,
startCommand,
installCommand,
runtime,
);
if (res) {
projects = [...projects, res];
createdProject = res;
}
deploying = false;
}
$effect(() => {
if (!newProjectOpen) {
setTimeout(() => {
createdProject = null;
repo = "";
name = "";
port = "";
gitToken = "";
buildCommand = "";
startCommand = "";
installCommand = "";
runtime = "nodejs";
envVars = [{ key: "", value: "" }];
}, 200);
}
});
</script>
<div class="container mx-auto py-10 px-4">
{#if !$user}
<div
class="flex flex-col items-center justify-center space-y-10 py-20 text-center"
>
Deploy with <span
class="bg-linear-to-r from-blue-400 to-purple-600 bg-clip-text text-transparent"
>One Click</span
>.
<p class="max-w-[600px] text-muted-foreground text-xl">
The simplified PaaS for your personal projects. Push, build, and scale
without the complexity.
</p>
<div class="flex gap-4">
<Button href="/login" size="lg">Get Started</Button>
<Button href="https://github.com/clickploy" variant="outline" size="lg">
<Github class="mr-2 h-4 w-4" /> GitHub
</Button>
</div>
</div>
{:else}
<div class="flex items-center justify-between mb-8">
<div>
<h2 class="text-2xl font-bold tracking-tight">Overview</h2>
<p class="text-muted-foreground">Manage your deployed applications.</p>
</div>
<div class="flex gap-2">
<Button variant="outline" href="/activity">
<Activity class="mr-2 h-4 w-4" /> Activity
</Button>
<Dialog.Root bind:open={newProjectOpen}>
<Dialog.Trigger>
<Button>
<Plus class="mr-2 h-4 w-4" /> New Project
</Button>
</Dialog.Trigger>
<Dialog.Content class="sm:max-w-[500px] max-h-[85vh] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>Deploy Project</Dialog.Title>
<Dialog.Description>
Enter repository details to start a new deployment.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
{#if createdProject}
<div class="space-y-4">
<div
class="rounded-md bg-green-50 p-4 text-green-900 border border-green-200"
>
<h4 class="font-bold flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-green-500"></div>
Project Created Successfully!
</h4>
</div>
<div class="space-y-2">
<Label>Webhook URL</Label>
<div class="flex items-center gap-2">
<Input
readonly
value={`http://localhost:8080/webhooks/trigger?project_id=${createdProject.ID}`}
/>
<Button
variant="outline"
size="icon"
onclick={() =>
navigator.clipboard.writeText(
`http://localhost:8080/webhooks/trigger?project_id=${createdProject.ID}`,
)}
>
Copy
</Button>
</div>
<p class="text-xs text-muted-foreground">
Add this to your Git provider's webhook settings.
</p>
</div>
<div class="space-y-2">
<Label>Webhook Secret</Label>
<div class="flex items-center gap-2">
<Input readonly value={createdProject.webhook_secret} />
<Button
variant="outline"
size="icon"
onclick={() =>
navigator.clipboard.writeText(
createdProject.webhook_secret,
)}
>
Copy
</Button>
</div>
</div>
<Button
class="w-full"
onclick={() => (newProjectOpen = false)}>Done</Button
>
</div>
{:else}
<div class="grid gap-2">
<Label for="repo"
>Repository URL <span class="text-red-500">*</span></Label
>
<Input
id="repo"
bind:value={repo}
placeholder="https://github.com/username/repo"
/>
</div>
<div class="grid gap-2">
<Label for="name"
>App Name <span class="text-red-500">*</span></Label
>
<Input id="name" placeholder="my-app" bind:value={name} />
</div>
<div class="grid gap-2">
<Label for="port">Port (Optional)</Label>
<Input id="port" placeholder="Auto" bind:value={port} />
</div>
<div class="grid gap-2">
<Label>Runtime / Package Manager</Label>
<Select.Root type="single" bind:value={runtime}>
<Select.Trigger class="w-full">
{runtime === "nodejs"
? "Node.js (npm)"
: runtime === "bun"
? "Bun"
: runtime === "deno"
? "Deno"
: runtime === "pnpm"
? "Node.js (pnpm)"
: "Select runtime"}
</Select.Trigger>
<Select.Content>
<Select.Item value="nodejs">Node.js (npm)</Select.Item>
<Select.Item value="bun">Bun</Select.Item>
<Select.Item value="deno">Deno</Select.Item>
<Select.Item value="pnpm">Node.js (pnpm)</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="token">Git Token (Private Repo)</Label>
<Input
id="token"
type="password"
placeholder="ghp_..."
bind:value={gitToken}
/>
</div>
<div class="border-t pt-4 mt-2">
<Collapsible.Root>
<Collapsible.Trigger
class="flex items-center justify-between w-full"
>
<div class="text-left">
<Label class="text-base font-semibold cursor-pointer"
>Build & Development Settings</Label
>
<p class="text-xs text-muted-foreground">
Override default build commands.
</p>
</div>
<ChevronsUpDown class="h-4 w-4 text-muted-foreground" />
</Collapsible.Trigger>
<Collapsible.Content class="space-y-3 pt-4">
<div class="grid gap-2">
<Label for="buildConfig">Build Command</Label>
<Input
id="buildConfig"
placeholder="npm run build"
bind:value={buildCommand}
/>
</div>
<div class="grid gap-2">
<Label for="startConfig">Start Command</Label>
<Input
id="startConfig"
placeholder="npm run start"
bind:value={startCommand}
/>
</div>
<div class="grid gap-2">
<Label for="installConfig">Install Command</Label>
<Input
id="installConfig"
placeholder="npm install"
bind:value={installCommand}
/>
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
<div class="border-t pt-4 mt-2">
<Collapsible.Root>
<Collapsible.Trigger
class="flex items-center justify-between w-full"
>
<div class="text-left">
<Label class="text-base font-semibold cursor-pointer"
>Environment Variables</Label
>
<p class="text-xs text-muted-foreground">
Configure runtime environment variables.
</p>
</div>
<ChevronsUpDown class="h-4 w-4 text-muted-foreground" />
</Collapsible.Trigger>
<Collapsible.Content class="space-y-2 pt-4">
{#each envVars as env, i}
<div class="flex gap-2">
<Input placeholder="Key" bind:value={env.key} />
<Input placeholder="Value" bind:value={env.value} />
<Button
variant="ghost"
size="icon"
onclick={() => removeEnvVar(i)}
>
<LogOut class="h-4 w-4 rotate-45" />
</Button>
</div>
{/each}
<Button
variant="outline"
size="sm"
onclick={addEnvVar}
class="w-full"
>
<Plus class="mr-2 h-4 w-4" /> Add Variable
</Button>
</Collapsible.Content>
</Collapsible.Root>
</div>
{/if}
</div>
{#if !createdProject}
<Dialog.Footer>
<Button onclick={handleDeploy} disabled={deploying}>
{#if deploying}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Deploying...
{:else}
Deploy
{/if}
</Button>
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>
</div>
</div>
{#if loading}
<div class="flex justify-center p-10">
<Loader2 class="h-8 w-8 animate-spin" />
</div>
{:else if projects.length === 0}
<div
class="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed"
>
<div
class="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center"
>
<h3 class="mt-4 text-lg font-semibold">No projects created</h3>
<p class="mb-4 mt-2 text-sm text-muted-foreground">
You haven't deployed any projects yet.
</p>
<Button onclick={() => (newProjectOpen = true)}>
<Plus class="mr-2 h-4 w-4" /> Add Project
</Button>
</div>
</div>
{:else}
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each projects as project}
{@const latestDeployment = project.deployments?.[0]}
{@const status = latestDeployment?.status || "unknown"}
<Card
class="group hover:shadow-lg transition-all duration-300 border-muted/60 hover:border-primary/50 cursor-pointer overflow-hidden relative"
>
<a href={`/projects/${project.ID}`} class="block h-full">
<CardHeader class="pb-3">
<div class="flex items-start justify-between">
<div class="space-y-1">
<CardTitle class="text-xl flex items-center gap-2">
{project.name}
</CardTitle>
<CardDescription class="flex items-center gap-1">
<Github class="h-3 w-3" />
{new URL(project.repo_url).pathname.slice(1)}
</CardDescription>
</div>
<span
class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold capitalize
{status === 'live'
? 'border-transparent bg-green-500/15 text-green-500'
: status === 'failed'
? 'border-transparent bg-red-500/15 text-red-500'
: status === 'building'
? 'border-transparent bg-yellow-500/15 text-yellow-500'
: 'border-transparent bg-gray-500/15 text-gray-400'}"
>
{status}
</span>
</div>
</CardHeader>
<CardContent>
<div
class="grid grid-cols-2 gap-4 text-sm text-muted-foreground mb-6"
>
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-wider opacity-70"
>Port</span
>
<span class="font-mono text-foreground">{project.port}</span
>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-wider opacity-70"
>Runtime</span
>
<div class="flex items-center gap-1.5">
<span class="font-medium text-foreground capitalize"
>{project.runtime || "nodejs"}</span
>
</div>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-wider opacity-70"
>Deployments</span
>
<span class="font-medium text-foreground"
>{project.deployments?.length || 0}</span
>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-wider opacity-70"
>Last Updated</span
>
<span class="font-medium text-foreground truncate">
{latestDeployment
? new Date(
latestDeployment.CreatedAt,
).toLocaleDateString()
: "Never"}
</span>
</div>
</div>
<div class="flex gap-2 mt-auto">
<Button
variant="secondary"
class="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
href={`/projects/${project.ID}`}
>
Manage
</Button>
{#if status === "live" && latestDeployment?.url}
<Button
variant="outline"
size="icon"
href={latestDeployment.url}
target="_blank"
onclick={(e) => e.stopPropagation()}
title="Visit App"
>
<ExternalLink class="h-4 w-4" />
</Button>
{/if}
</div>
</CardContent>
</a>
</Card>
{/each}
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,158 @@
<script lang="ts">
import { onMount } from "svelte";
import { listActivity } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import {
Loader2,
Activity,
GitCommit,
ExternalLink,
Globe,
} from "@lucide/svelte";
let activities = $state<any[]>([]);
let loading = $state(true);
onMount(async () => {
const res = await listActivity();
if (res) activities = res;
loading = false;
});
function getStatusColor(status: string) {
switch (status) {
case "live":
return "bg-green-500";
case "failed":
return "bg-red-500";
case "building":
return "bg-yellow-500 animate-pulse";
default:
return "bg-gray-500";
}
}
</script>
<div class="container mx-auto py-8 px-4 max-w-5xl">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-muted rounded-lg border">
<Activity class="h-5 w-5 text-foreground" />
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight">Activity Feed</h1>
<p class="text-sm text-muted-foreground">
Recent deployments across all projects.
</p>
</div>
</div>
</div>
{#if loading}
<div class="flex justify-center p-20">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
{:else}
<Card class="border-border/60">
<CardContent class="p-0">
{#if activities.length}
<div
class="grid grid-cols-12 gap-4 px-4 py-3 border-b text-xs font-medium text-muted-foreground bg-muted/40 uppercase tracking-wider"
>
<div class="col-span-3">Project</div>
<div class="col-span-4">Commit</div>
<div class="col-span-2">Status</div>
<div class="col-span-3 text-right">Time</div>
</div>
<div class="divide-y divide-border/40">
{#each activities as activity}
<div
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-colors text-sm group"
>
<div class="col-span-3 flex items-center gap-2 overflow-hidden">
<div
class="h-8 w-8 rounded bg-primary/10 flex items-center justify-center shrink-0 text-primary uppercase font-bold text-xs ring-1 ring-inset ring-primary/20"
>
{activity.project?.name?.substring(0, 2) || "??"}
</div>
<a
href={`/projects/${activity.project_id}`}
class="font-semibold hover:underline truncate"
>
{activity.project?.name || "Unknown"}
</a>
</div>
<div
class="col-span-4 flex items-center gap-2 font-mono text-xs text-muted-foreground"
>
<GitCommit class="h-3.5 w-3.5 shrink-0" />
<span
class="bg-muted px-1.5 py-0.5 rounded border border-border/50 text-foreground/80 shrink-0"
>
{activity.commit ? activity.commit.substring(0, 7) : "HEAD"}
</span>
<span class="truncate hidden sm:inline-block opacity-70">
{activity.commit === "MANUAL"
? "Manual Redeploy"
: activity.commit === "WEBHOOK"
? "Webhook Trigger"
: "Git Push"}
</span>
</div>
<div class="col-span-2">
<span
class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border
{activity.status === 'live'
? 'border-green-500/20 bg-green-500/10 text-green-500'
: activity.status === 'failed'
? 'border-red-500/20 bg-red-500/10 text-red-500'
: 'border-yellow-500/20 bg-yellow-500/10 text-yellow-500'}"
>
<span
class="h-1.5 w-1.5 rounded-full {getStatusColor(
activity.status,
)}"
></span>
<span class="capitalize">{activity.status}</span>
</span>
</div>
<div
class="col-span-3 flex items-center justify-end gap-3 text-right"
>
<span class="text-xs text-muted-foreground">
{new Date(activity.CreatedAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
href={`/projects/${activity.project_id}`}
>
<ExternalLink class="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</div>
{/each}
</div>
{:else}
<div
class="p-12 text-center text-muted-foreground flex flex-col items-center justify-center gap-2"
>
<Globe class="h-8 w-8 opacity-20" />
<p>No activity found.</p>
</div>
{/if}
</CardContent>
</Card>
{/if}
</div>

View File

@@ -0,0 +1,134 @@
<script lang="ts">
import { onMount } from "svelte";
import { listActivity } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "$lib/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "$lib/components/ui/table";
import { Loader2, Rocket, GitCommit, ExternalLink } from "@lucide/svelte";
let deployments = $state<any[]>([]);
let loading = $state(true);
onMount(async () => {
const res = await listActivity();
if (res) deployments = res;
loading = false;
});
</script>
<div class="container mx-auto py-10 px-4">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Deployments</h1>
<p class="text-muted-foreground">
View and manage deployments across all your projects.
</p>
</div>
</div>
{#if loading}
<div class="flex justify-center p-10">
<Loader2 class="h-8 w-8 animate-spin" />
</div>
{:else}
<Card>
<CardHeader>
<CardTitle>All Deployments</CardTitle>
<CardDescription>
A list of recent deployments from all projects.
</CardDescription>
</CardHeader>
<CardContent>
{#if deployments.length === 0}
<div
class="flex flex-col items-center justify-center py-10 text-center"
>
<Rocket class="h-10 w-10 text-muted-foreground mb-4" />
<h3 class="text-lg font-medium">No deployments yet</h3>
<p class="text-muted-foreground">
Deploy a project to see it appear here.
</p>
</div>
{:else}
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Commit</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each deployments as deploy}
<TableRow>
<TableCell class="font-medium">
{#if deploy.project}
<a
href={`/projects/${deploy.project.ID}`}
class="hover:underline flex items-center gap-2"
>
{deploy.project.name}
</a>
{:else}
<span class="text-muted-foreground">Deleted Project</span>
{/if}
</TableCell>
<TableCell>
<div class="flex items-center gap-2">
<GitCommit class="h-4 w-4 text-muted-foreground" />
<span class="font-mono text-sm"
>{deploy.commit?.substring(0, 7) || "HEAD"}</span
>
</div>
</TableCell>
<TableCell>
<span
class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold
{deploy.status === 'live'
? 'border-transparent bg-green-500/15 text-green-500'
: deploy.status === 'failed'
? 'border-transparent bg-red-500/15 text-red-500'
: 'border-transparent bg-yellow-500/15 text-yellow-500'}"
>
{deploy.status}
</span>
</TableCell>
<TableCell class="text-muted-foreground">
{new Date(deploy.CreatedAt).toLocaleDateString()}
{new Date(deploy.CreatedAt).toLocaleTimeString()}
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
href={deploy.url}
target="_blank"
disabled={!deploy.url}
>
<ExternalLink class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{/if}
</CardContent>
</Card>
{/if}
</div>

View File

@@ -0,0 +1,121 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import { onMount } from "svelte";
import { listProjects, type Project } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "$lib/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "$lib/components/ui/table";
import {
Loader2,
Network,
ExternalLink,
Globe,
Server,
} from "@lucide/svelte";
let projects = $state<Project[]>([]);
let loading = $state(true);
onMount(async () => {
const res = await listProjects();
if (res) projects = res;
loading = false;
});
</script>
<div class="container mx-auto py-10 px-4">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Network</h1>
<p class="text-muted-foreground">
Active services and network configuration.
</p>
</div>
</div>
{#if loading}
<div class="flex justify-center p-10">
<Loader2 class="h-8 w-8 animate-spin" />
</div>
{:else}
<div class="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Active Services</CardTitle>
<CardDescription>
Overview of deployed applications and their internal/external ports.
</CardDescription>
</CardHeader>
<CardContent>
{#if projects.length === 0}
<div
class="flex flex-col items-center justify-center py-10 text-center"
>
<Network class="h-10 w-10 text-muted-foreground mb-4" />
<h3 class="text-lg font-medium">No services found</h3>
<p class="text-muted-foreground">
Deploy a project to populate the network map.
</p>
</div>
{:else}
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Host</TableHead>
<TableHead>Port</TableHead>
<TableHead>URL</TableHead>
<TableHead>Status</TableHead>
<TableHead class="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each projects as project}
<TableRow>
<TableCell class="font-medium">
<div class="flex items-center gap-2">
<Server class="h-4 w-4 text-muted-foreground" />
<a
href={`/projects/${project.ID}`}
class="hover:underline"
>
{project.name}
</a>
</div>
</TableCell>
<TableCell class="font-mono text-xs">localhost</TableCell>
<TableCell class="font-mono text-xs"
>{project.port}</TableCell
>
<TableCell class="font-mono text-xs text-muted-foreground">
http://localhost:{project.port}
</TableCell>
<TableCell>
<span
class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold
bg-green-500/15 text-green-500 border-transparent"
>
Active
</span>
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
href={`http://localhost:${project.port}`}
target="_blank"
>
<ExternalLink class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{/if}
</CardContent>
</Card>
</div>
{/if}
</div>

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { page } from "$app/stores";
import { Button } from "$lib/components/ui/button";
import { Toaster } from "svelte-sonner";
import {
ArrowLeft,
LayoutDashboard,
Settings,
Activity,
GitCommit,
} from "@lucide/svelte";
let { children } = $props();
let projectId = $derived($page.params.id);
function isActive(path: string) {
return $page.url.pathname.endsWith(path)
? "bg-secondary"
: "hover:bg-secondary/50";
}
</script>
<div class="container mx-auto py-6 px-4">
<div class="mb-6 flex items-center gap-4">
<Button variant="ghost" size="icon" href="/">
<ArrowLeft class="h-4 w-4" />
</Button>
<h1 class="text-2xl font-bold tracking-tight">Project Dashboard</h1>
</div>
<div class="grid grid-cols-1 md:grid-cols-[250px_1fr] gap-8">
<aside class="space-y-2">
<Button
href={`/projects/${projectId}`}
variant="ghost"
class={`w-full justify-start ${$page.url.pathname === `/projects/${projectId}` ? "bg-secondary" : ""}`}
>
<LayoutDashboard class="mr-2 h-4 w-4" /> Overview
</Button>
<Button
href={`/projects/${projectId}/deployments`}
variant="ghost"
class={`w-full justify-start ${isActive("/deployments")}`}
>
<GitCommit class="mr-2 h-4 w-4" /> Deployments
</Button>
<Button
href={`/projects/${projectId}/settings`}
variant="ghost"
class={`w-full justify-start ${isActive("/settings")}`}
>
<Settings class="mr-2 h-4 w-4" /> Settings
</Button>
</aside>
<main class="min-h-[500px]">
{@render children()}
</main>
</div>
</div>

View File

@@ -0,0 +1,406 @@
<script lang="ts">
import { onMount, tick } from "svelte";
import { page } from "$app/stores";
import { getProject, type Project, redeployProject } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import { Card } from "$lib/components/ui/card";
import {
Loader2,
ExternalLink,
RefreshCw,
Activity,
Terminal,
Settings,
Play,
Check,
Copy,
GitCommit,
} from "@lucide/svelte";
import { toast } from "svelte-sonner";
let project = $state<Project | null>(null);
let loading = $state(true);
let latestDeployment = $derived(project?.deployments?.[0]);
let status = $derived(latestDeployment?.status || "unknown");
let activeDeploymentLogs = $state("");
let activeDeploymentId = $state<number | null>(null);
let ws = $state<WebSocket | null>(null);
let logContentRef = $state<HTMLDivElement | null>(null);
let copied = $state(false);
let autoScroll = $state(true);
let userScrolled = $state(false);
onMount(async () => {
loadProject();
});
async function loadProject() {
const id = $page.params.id;
if (id) {
const res = await getProject(id);
if (res) {
project = res;
if (
!activeDeploymentId &&
res.deployments &&
res.deployments.length > 0
) {
selectDeployment(res.deployments[0]);
}
}
}
loading = false;
}
async function refresh() {
loading = true;
await loadProject();
loading = false;
}
async function handleRedeploy() {
if (!project) return;
toast.info("Starting redeployment...");
const success = await redeployProject(project.ID.toString());
if (success) {
toast.success("Redeployment started!");
setTimeout(loadProject, 1000);
}
}
function selectDeployment(deployment: any) {
if (activeDeploymentId === deployment.ID) return;
activeDeploymentId = deployment.ID;
activeDeploymentLogs = deployment.logs || "";
userScrolled = false;
autoScroll = true;
scrollToBottom(true);
if (deployment.status === "building") {
connectWebSocket(deployment.ID);
} else {
if (ws) {
ws.close();
ws = null;
}
}
}
function connectWebSocket(deploymentId: number) {
if (ws) ws.close();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(
`${protocol}//${window.location.hostname}:8080/api/deployments/${deploymentId}/logs/stream`,
);
ws.onmessage = (event) => {
activeDeploymentLogs += event.data;
if (autoScroll) {
scrollToBottom();
}
};
ws.onclose = () => {
console.log("Log stream closed");
};
}
async function scrollToBottom(force = false) {
await tick();
if (logContentRef) {
logContentRef.scrollTop = logContentRef.scrollHeight;
}
}
function handleScroll() {
if (!logContentRef) return;
const { scrollTop, scrollHeight, clientHeight } = logContentRef;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
if (!isAtBottom) {
userScrolled = true;
autoScroll = false;
} else {
userScrolled = false;
autoScroll = true;
}
}
function copyLogs() {
navigator.clipboard.writeText(activeDeploymentLogs);
copied = true;
setTimeout(() => (copied = false), 2000);
toast.success("Logs copied to clipboard");
}
</script>
{#if loading}
<div class="flex justify-center items-center h-[50vh]">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
{:else if project}
<div class="space-y-4 h-[calc(100vh-140px)] flex flex-col overflow-hidden">
<div class="shrink-0 space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold tracking-tight">{project.name}</h2>
<a
href={project.repo_url}
target="_blank"
class="text-muted-foreground hover:text-primary transition-colors text-xs flex items-center gap-1 border px-2 py-0.5 rounded-full"
>
<GitCommit class="h-3 w-3" />
{project.repo_url.split("/").pop()?.replace(".git", "")}
</a>
</div>
<div class="flex gap-2">
<Button
variant="ghost"
size="sm"
class="h-8"
onclick={refresh}
title="Refresh Project"
>
<RefreshCw class="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={handleRedeploy}
disabled={status === "building"}
>
{#if status === "building"}
<Loader2 class="h-3.5 w-3.5 mr-2 animate-spin" /> Redeploying
{:else}
<Play class="h-3.5 w-3.5 mr-2" /> Redeploy
{/if}
</Button>
<Button
href={latestDeployment?.url}
target="_blank"
disabled={status !== "live"}
size="sm"
class="h-8"
>
<ExternalLink class="mr-2 h-3.5 w-3.5" /> Visit
</Button>
</div>
</div>
<div
class="grid grid-cols-4 gap-px bg-border/40 rounded-lg overflow-hidden border"
>
<div
class="bg-card p-3 flex flex-col justify-center items-center relative overflow-hidden group"
>
<div
class="absolute inset-0 opacity-5 transition-opacity group-hover:opacity-10 {status ===
'live'
? 'bg-green-500'
: status === 'failed'
? 'bg-red-500'
: 'bg-yellow-500'}"
></div>
<div
class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold mb-0.5"
>
Status
</div>
<div
class="font-bold flex items-center gap-2 {status === 'live'
? 'text-green-500'
: status === 'failed'
? 'text-red-500'
: 'text-yellow-500'}"
>
<Activity class="h-3.5 w-3.5" />
{status}
</div>
</div>
<div class="bg-card p-3 flex flex-col justify-center items-center">
<div
class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold mb-0.5"
>
Runtime
</div>
<div class="font-bold flex items-center gap-2">
<Terminal class="h-3.5 w-3.5" />
{project.runtime || "nodejs"}
</div>
</div>
<div class="bg-card p-3 flex flex-col justify-center items-center">
<div
class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold mb-0.5"
>
Deployments
</div>
<div class="font-bold flex items-center gap-2">
<RefreshCw class="h-3.5 w-3.5" />
{project.deployments?.length || 0}
</div>
</div>
<div class="bg-card p-3 flex flex-col justify-center items-center">
<div
class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold mb-0.5"
>
Config
</div>
<div class="font-bold flex items-center gap-2">
<Settings class="h-3.5 w-3.5" />
{project.env_vars?.length || 0} vars
</div>
</div>
</div>
</div>
<div class="flex-1 grid grid-cols-1 lg:grid-cols-4 gap-4 min-h-0">
<Card
class="flex flex-col min-h-0 bg-transparent shadow-none border-0 lg:col-span-1"
>
<div class="flex items-center justify-between px-1 pb-2">
<h3 class="text-sm font-semibold text-muted-foreground">History</h3>
</div>
<div class="flex-1 overflow-y-auto pr-1 space-y-1">
{#if project.deployments?.length}
{#each project.deployments as deployment}
<button
class="w-full flex items-center justify-between p-2.5 rounded-md border text-left transition-all text-xs
{activeDeploymentId === deployment.ID
? 'bg-primary/5 border-primary/20 shadow-sm'
: 'bg-card hover:bg-muted/50 border-input'}"
onclick={() => selectDeployment(deployment)}
>
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2">
<span class="font-semibold">#{deployment.ID}</span>
<span
class="font-mono text-[10px] text-muted-foreground bg-muted px-1 rounded flex items-center gap-1"
>
<GitCommit class="h-2 w-2" />
{deployment.commit
? deployment.commit.substring(0, 7)
: "HEAD"}
</span>
</div>
<span class="text-[10px] text-muted-foreground truncate">
{new Date(deployment.CreatedAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
<div class="flex items-center gap-2">
{#if deployment.status === "building"}
<Loader2 class="h-3 w-3 animate-spin text-yellow-500" />
{:else}
<div
class="h-2 w-2 rounded-full {deployment.status === 'live'
? 'bg-green-500'
: deployment.status === 'failed'
? 'bg-red-500'
: 'bg-gray-300'}"
></div>
{/if}
</div>
</button>
{/each}
{:else}
<div
class="p-4 text-center text-xs text-muted-foreground border rounded-md border-dashed"
>
No deployments
</div>
{/if}
</div>
</Card>
<div
class="lg:col-span-3 flex flex-col min-h-0 rounded-lg border bg-zinc-950 shadow-sm overflow-hidden border-border/40"
>
<div
class="flex shrink-0 items-center justify-between px-3 py-2 bg-zinc-900/50 border-b border-white/5"
>
<div class="flex items-center gap-2">
<Terminal class="h-3.5 w-3.5 text-zinc-400" />
<span class="text-xs font-mono text-zinc-300">
{#if activeDeploymentId}
build-log-{activeDeploymentId}.log
{:else}
waiting-for-selection...
{/if}
</span>
{#if ws}
<span class="flex h-1.5 w-1.5 relative ml-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
></span>
<span
class="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500"
></span>
</span>
{/if}
</div>
<div class="flex items-center gap-1">
{#if userScrolled}
<Button
variant="ghost"
size="sm"
class="h-5 text-[10px] px-2 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 mr-2"
onclick={() => {
autoScroll = true;
userScrolled = false;
scrollToBottom(true);
}}
>
Resume Scroll
</Button>
{/if}
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-zinc-400 hover:text-white"
onclick={copyLogs}
title="Copy Logs"
>
{#if copied}
<Check class="h-3 w-3 text-green-500" />
{:else}
<Copy class="h-3 w-3" />
{/if}
</Button>
</div>
</div>
<div
bind:this={logContentRef}
onscroll={handleScroll}
class="flex-1 overflow-auto p-3 font-mono text-[11px] leading-relaxed text-gray-200 scrollbar-thin scrollbar-thumb-zinc-800 scrollbar-track-transparent selection:bg-white/20"
>
{#if activeDeploymentLogs}
<pre
class="whitespace-pre-wrap break-all">{activeDeploymentLogs}</pre>
{:else}
<div
class="flex h-full items-center justify-center text-zinc-600 italic text-xs"
>
<p>Select a deployment to view logs</p>
</div>
{/if}
<div class="h-px w-full"></div>
</div>
</div>
</div>
</div>
{:else}
<div class="flex flex-col items-center justify-center space-y-4 py-20">
<p class="text-lg text-muted-foreground">Project not found.</p>
<Button href="/">Go Back</Button>
</div>
{/if}

View File

@@ -0,0 +1,192 @@
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/stores";
import { getProject, type Project } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "$lib/components/ui/card";
import {
Loader2,
ExternalLink,
GitCommit,
Search,
RefreshCw,
Terminal,
CheckCircle2,
XCircle,
AlertCircle,
} from "@lucide/svelte";
let project = $state<Project | null>(null);
let loading = $state(true);
let searchTerm = $state("");
onMount(async () => {
const id = $page.params.id;
if (id) {
const res = await getProject(id);
if (res) project = res;
}
loading = false;
});
let filteredDeployments = $derived(
project?.deployments?.filter(
(d) =>
d.commit.toLowerCase().includes(searchTerm.toLowerCase()) ||
d.status.toLowerCase().includes(searchTerm.toLowerCase()) ||
d.ID.toString().includes(searchTerm),
) || [],
);
function getStatusColor(status: string) {
switch (status) {
case "live":
return "text-green-500";
case "failed":
return "text-red-500";
case "building":
return "text-yellow-500";
default:
return "text-muted-foreground";
}
}
function getStatusIcon(status: string) {
switch (status) {
case "live":
return CheckCircle2;
case "failed":
return XCircle;
case "building":
return RefreshCw;
default:
return AlertCircle;
}
}
</script>
{#if loading}
<div class="flex justify-center p-10">
<Loader2 class="h-8 w-8 animate-spin" />
</div>
{:else if project}
<div class="space-y-4">
<div class="flex justify-between items-center">
<div>
<h2 class="text-xl font-bold tracking-tight">Deployments</h2>
<p class="text-sm text-muted-foreground">
History of your application builds.
</p>
</div>
</div>
<Card class="border-border/60">
<CardContent class="p-0">
{#if project.deployments?.length}
<div
class="grid grid-cols-12 gap-4 px-4 py-3 border-b text-xs font-medium text-muted-foreground bg-muted/40 uppercase tracking-wider"
>
<div class="col-span-1">ID</div>
<div class="col-span-2">Status</div>
<div class="col-span-5">Commit</div>
<div class="col-span-3">Date</div>
<div class="col-span-1 text-right">Actions</div>
</div>
<div class="divide-y divide-border/40">
{#each filteredDeployments as deployment}
{@const StatusIcon = getStatusIcon(deployment.status)}
<div
class="grid grid-cols-12 gap-4 px-4 py-2.5 items-center hover:bg-muted/30 transition-colors text-sm group"
>
<div class="col-span-1 font-mono text-xs text-muted-foreground">
#{deployment.ID}
</div>
<div class="col-span-2 flex items-center gap-2">
<StatusIcon
class="h-4 w-4 {getStatusColor(
deployment.status,
)} {deployment.status === 'building' ? 'animate-spin' : ''}"
/>
<span
class="capitalize font-medium {getStatusColor(
deployment.status,
)} text-xs"
>
{deployment.status}
</span>
</div>
<div
class="col-span-5 flex items-center gap-2 font-mono text-xs"
>
<GitCommit class="h-3.5 w-3.5 text-muted-foreground" />
<span
class="bg-muted px-1.5 py-0.5 rounded border border-border/50 text-foreground/80"
>
{deployment.commit
? deployment.commit.substring(0, 7)
: "HEAD"}
</span>
<span
class="text-muted-foreground truncate hidden md:inline-block max-w-[200px]"
>
{deployment.commit === "MANUAL"
? "Manual Redeploy"
: deployment.commit === "WEBHOOK"
? "Webhook Trigger"
: "Git Push"}
</span>
</div>
<div class="col-span-3 text-xs text-muted-foreground">
{new Date(deployment.CreatedAt).toLocaleString()}
</div>
<div
class="col-span-1 flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
{#if deployment.status === "live"}
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
href={deployment.url}
target="_blank"
title="Visit App"
>
<ExternalLink class="h-3.5 w-3.5" />
</Button>
{/if}
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
href={`/projects/${project.ID}?deployment=${deployment.ID}`}
title="View Logs"
>
<Terminal class="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/each}
</div>
{:else}
<div
class="p-12 text-center flex flex-col items-center justify-center text-muted-foreground gap-2"
>
<Search class="h-8 w-8 opacity-20" />
<p>No deployments found.</p>
</div>
{/if}
</CardContent>
</Card>
</div>
{/if}

View File

@@ -0,0 +1,239 @@
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/stores";
import { getProject, type Project, updateProjectEnv } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "$lib/components/ui/card";
import {
Loader2,
Save,
Trash2,
Eye,
EyeOff,
Plus,
Copy,
} from "@lucide/svelte";
import { toast } from "svelte-sonner";
let project = $state<Project | null>(null);
let loading = $state(true);
let showSecret = $state(false);
let tempEnvVars = $state<{ key: string; value: string }[]>([]);
let isDirty = $state(false);
onMount(async () => {
const id = $page.params.id;
if (id) {
const res = await getProject(id);
if (res) {
project = res;
initEnvVars();
}
}
loading = false;
});
function initEnvVars() {
if (project?.env_vars) {
tempEnvVars = project.env_vars.map((e) => ({
key: e.key,
value: e.value,
}));
} else {
tempEnvVars = [];
}
isDirty = false;
}
function toggleSecret() {
showSecret = !showSecret;
}
function addEnvVar() {
tempEnvVars = [...tempEnvVars, { key: "", value: "" }];
isDirty = true;
}
function removeEnvVar(index: number) {
tempEnvVars = tempEnvVars.filter((_, i) => i !== index);
isDirty = true;
}
function handleKeyInput(index: number, e: Event) {
const target = e.target as HTMLInputElement;
tempEnvVars[index].key = target.value;
isDirty = true;
}
function handleValueInput(index: number, e: Event) {
const target = e.target as HTMLInputElement;
tempEnvVars[index].value = target.value;
isDirty = true;
}
async function saveEnvVars() {
if (!project) return;
const envMap: Record<string, string> = {};
for (const e of tempEnvVars) {
if (e.key.trim()) envMap[e.key.trim()] = e.value;
}
loading = true;
const success = await updateProjectEnv(project.ID.toString(), envMap);
loading = false;
if (success) {
toast.success("Environment variables updated successfully");
const res = await getProject(project.ID.toString());
if (res) {
project = res;
initEnvVars();
}
} else {
toast.error("Failed to update environment variables");
}
}
function copyWebhook() {
if (!project) return;
const displayUrl = `http://localhost:8080/webhooks/trigger?project_id=${project.ID}`;
navigator.clipboard.writeText(displayUrl);
toast.success("Webhook URL copied");
}
</script>
{#if loading && !project}
<div class="flex justify-center p-10">
<Loader2 class="h-8 w-8 animate-spin" />
</div>
{:else if project}
<div class="space-y-6 max-w-4xl">
<Card class="border-border/60">
<CardHeader class="pb-4">
<div class="flex items-center justify-between">
<div>
<CardTitle class="text-xl font-bold"
>Environment Variables</CardTitle
>
<CardDescription class="mt-1 text-sm text-muted-foreground">
Configure runtime environment variables.
</CardDescription>
</div>
<Button variant="ghost" size="sm" onclick={toggleSecret} class="h-8">
{#if showSecret}
<EyeOff class="h-4 w-4" />
{:else}
<Eye class="h-4 w-4" />
{/if}
</Button>
</div>
</CardHeader>
<CardContent class="space-y-3 pb-4">
{#each tempEnvVars as env, i}
<div class="flex gap-3 items-center">
<Input
placeholder="Key"
value={env.key}
oninput={(e) => handleKeyInput(i, e)}
class="flex-1 h-11 bg-background/50 border-border/60 font-mono"
/>
<Input
placeholder="Value"
value={env.value}
type={showSecret ? "text" : "password"}
oninput={(e) => handleValueInput(i, e)}
class="flex-1 h-11 bg-background/50 border-border/60"
/>
<Button
variant="ghost"
size="icon"
class="h-11 w-11 shrink-0"
onclick={() => removeEnvVar(i)}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
{/each}
<Button
variant="outline"
class="w-full h-11 border-dashed border-border/60 hover:bg-muted/50"
onclick={addEnvVar}
>
<Plus class="h-4 w-4 mr-2" />
Add Variable
</Button>
</CardContent>
<CardFooter class="border-t px-4 flex justify-end">
<Button onclick={saveEnvVars} disabled={!isDirty || loading} size="sm">
{#if loading}
<Loader2 class="h-4 w-4 mr-2 animate-spin" /> Saving...
{:else}
<Save class="h-4 w-4 mr-2" /> Save Changes
{/if}
</Button>
</CardFooter>
</Card>
<Card class="border-border/60">
<CardHeader>
<CardTitle class="text-lg">Webhook Integration</CardTitle>
<CardDescription class="mt-1.5">
Trigger deployments automatically when you push to your repository.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label class="text-sm">Webhook URL</Label>
<div class="flex items-center gap-2">
<Input
readonly
value={`http://localhost:8080/webhooks/trigger?project_id=${project.ID}`}
class="bg-muted font-mono text-xs"
/>
<Button variant="outline" size="icon" onclick={copyWebhook}>
<Copy class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-2">
<Label class="text-sm">Webhook Secret</Label>
<Input
type="password"
readonly
value={project.webhook_secret}
class="bg-muted font-mono text-xs"
/>
</div>
</CardContent>
</Card>
<Card class="border-destructive/50 bg-destructive/5">
<CardHeader>
<CardTitle class="text-destructive text-lg">Danger Zone</CardTitle>
</CardHeader>
<CardContent>
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium">Delete Project</h4>
<p class="text-sm text-muted-foreground">
This action cannot be undone.
</p>
</div>
<Button variant="destructive">Delete Project</Button>
</div>
</CardContent>
</Card>
</div>
{/if}

View File

@@ -0,0 +1,105 @@
<script lang="ts">
import { user } from "$lib/auth";
import { updateProfile, updatePassword } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "$lib/components/ui/card";
import { ArrowLeft } from "@lucide/svelte";
import { toast } from "svelte-sonner";
let name = $state($user?.name || "");
let email = $state($user?.email || "");
let oldPassword = $state("");
let newPassword = $state("");
let loading = $state(false);
async function handleUpdateProfile(e: Event) {
e.preventDefault();
loading = true;
const res = await updateProfile(name, email);
if (res) {
toast.success("Profile updated");
user.update((u) => {
if (!u) return null;
return { ...u, name: res.name, email: res.email };
});
}
loading = false;
}
async function handleUpdatePassword(e: Event) {
e.preventDefault();
loading = true;
const res = await updatePassword(oldPassword, newPassword);
if (res) {
toast.success("Password updated");
oldPassword = "";
newPassword = "";
}
loading = false;
}
</script>
<div class="container mx-auto py-10 px-4 max-w-2xl">
<div class="mb-6">
<h1 class="text-3xl font-bold tracking-tight">Account Settings</h1>
<p class="text-muted-foreground">Manage your profile and preferences.</p>
</div>
{#if $user}
<div class="space-y-6">
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>Your personal information.</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<form class="space-y-4" onsubmit={handleUpdateProfile}>
<div class="space-y-2">
<Label>Name</Label>
<Input bind:value={name} />
</div>
<div class="space-y-2">
<Label>Email</Label>
<Input bind:value={email} />
</div>
<Button type="submit" disabled={loading}>Update Profile</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Security</CardTitle>
<CardDescription>Change your password.</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" onsubmit={handleUpdatePassword}>
<div class="space-y-2">
<Label>Current Password</Label>
<Input type="password" bind:value={oldPassword} required />
</div>
<div class="space-y-2">
<Label>New Password</Label>
type="password" bind:value={newPassword}
required minlength={6}
/>
</div>
<Button type="submit" variant="secondary" disabled={loading}
>Change Password</Button
>
</form>
</CardContent>
</Card>
</div>
{:else}
<p>Please log in.</p>
{/if}
</div>

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import {
Database,
HardDrive,
Plus,
Server,
AlertCircle,
} from "@lucide/svelte";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "$lib/components/ui/card";
import { Progress } from "$lib/components/ui/progress";
import { getStorageStats, listDatabases, createDatabase } from "$lib/api";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
let totalStorage = $state(0);
let usedStorage = $state(0);
let usagePercent = $derived(
totalStorage > 0 ? (usedStorage / totalStorage) * 100 : 0,
);
let userDatabases = $state<any[]>([]);
let loading = $state(true);
const availableTypes = [
{
name: "SQLite",
description: "Embedded, serverless database engine.",
type: "sqlite",
status: "Available",
},
{
name: "PostgreSQL",
description: "Advanced open source relational database.",
type: "postgres",
status: "Coming Soon",
},
{
name: "Redis",
description: "In-memory data structure store.",
type: "redis",
status: "Coming Soon",
},
];
async function loadData() {
const [stats, dbs] = await Promise.all([
getStorageStats(),
listDatabases(),
]);
if (stats) {
totalStorage = stats.total_mb;
usedStorage = stats.used_mb;
}
if (dbs) {
userDatabases = dbs;
}
loading = false;
}
async function handleCreate(type: string) {
const name = prompt("Enter database name:");
if (!name) return;
const res = await createDatabase(name, type);
if (res) {
toast.success("Database created successfully!");
loadData();
}
}
onMount(loadData);
</script>
<div class="container mx-auto py-10 px-4">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Storage</h1>
<p class="text-muted-foreground">
Manage databases and view storage usage.
</p>
</div>
<Button>
<Plus class="mr-2 h-4 w-4" /> Create Database
</Button>
</div>
<div class="grid gap-6 md:grid-cols-3 mb-8">
<Card class="md:col-span-3">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<HardDrive class="h-5 w-5" /> Storage Usage
</CardTitle>
<CardDescription>
Total disk space used on the host machine.
</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-4">
<div class="flex items-center justify-between text-sm">
<span class="font-medium"
>{(usedStorage / 1024).toFixed(2)} GB used</span
>
<span class="text-muted-foreground"
>{(totalStorage / 1024).toFixed(2)} GB total</span
>
</div>
<Progress value={usagePercent} class="h-2" />
<p class="text-xs text-muted-foreground">
You are using {usagePercent.toFixed(1)}% of available storage.
</p>
</div>
</CardContent>
</Card>
</div>
<h2 class="text-xl font-semibold tracking-tight mb-4">Your Databases</h2>
{#if userDatabases.length === 0}
<div
class="rounded-lg border border-dashed p-8 text-center text-muted-foreground mb-8"
>
No databases created yet.
</div>
{:else}
<div class="grid gap-4 mb-8">
{#each userDatabases as db}
<div
class="flex items-center justify-between rounded-lg border p-4 bg-muted/20"
>
<div class="flex items-center gap-4">
<div class="rounded-full bg-blue-500/10 p-2">
<Database class="h-6 w-6 text-blue-500" />
</div>
<div>
<h3 class="font-semibold">{db.name}</h3>
<p class="text-sm text-muted-foreground uppercase">
{db.type}{new Date(db.CreatedAt).toLocaleDateString()}
</p>
</div>
</div>
<span
class="text-xs font-medium px-2.5 py-0.5 rounded-full bg-green-500/15 text-green-500"
>
{db.status}
</span>
</div>
{/each}
</div>
{/if}
<h2 class="text-xl font-semibold tracking-tight mb-4">Create New</h2>
<div class="grid gap-4">
{#each availableTypes as db}
<div
class="flex items-center justify-between rounded-lg border p-4 hover:bg-muted/50 transition-colors"
>
<div class="flex items-center gap-4">
<div class="rounded-full bg-primary/10 p-2">
<Server class="h-6 w-6 text-primary" />
</div>
<div>
<h3 class="font-semibold">{db.name}</h3>
<p class="text-sm text-muted-foreground">{db.description}</p>
</div>
</div>
<div class="flex items-center gap-4">
<span
class="text-xs font-medium px-2.5 py-0.5 rounded-full {db.status ===
'Available'
? 'bg-green-500/15 text-green-500'
: 'bg-yellow-500/15 text-yellow-500'}"
>
{db.status}
</span>
<Button
variant="outline"
disabled={db.status !== "Available"}
onclick={() => handleCreate(db.type)}
>
Create
</Button>
</div>
</div>
{/each}
</div>
</div>