Initial commit
This commit is contained in:
13
frontend/src/app.d.ts
vendored
Normal file
13
frontend/src/app.d.ts
vendored
Normal 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
11
frontend/src/app.html
Normal 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
230
frontend/src/lib/api.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal 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
33
frontend/src/lib/auth.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
68
frontend/src/lib/components/Footer.svelte
Normal file
68
frontend/src/lib/components/Footer.svelte
Normal 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>
|
||||
183
frontend/src/lib/components/Navbar.svelte
Normal file
183
frontend/src/lib/components/Navbar.svelte
Normal 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>
|
||||
@@ -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>
|
||||
20
frontend/src/lib/components/ui/alert/alert-title.svelte
Normal file
20
frontend/src/lib/components/ui/alert/alert-title.svelte
Normal 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>
|
||||
44
frontend/src/lib/components/ui/alert/alert.svelte
Normal file
44
frontend/src/lib/components/ui/alert/alert.svelte
Normal 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>
|
||||
14
frontend/src/lib/components/ui/alert/index.ts
Normal file
14
frontend/src/lib/components/ui/alert/index.ts
Normal 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,
|
||||
};
|
||||
50
frontend/src/lib/components/ui/badge/badge.svelte
Normal file
50
frontend/src/lib/components/ui/badge/badge.svelte
Normal 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>
|
||||
2
frontend/src/lib/components/ui/badge/index.ts
Normal file
2
frontend/src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
82
frontend/src/lib/components/ui/button/button.svelte
Normal file
82
frontend/src/lib/components/ui/button/button.svelte
Normal 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}
|
||||
17
frontend/src/lib/components/ui/button/index.ts
Normal file
17
frontend/src/lib/components/ui/button/index.ts
Normal 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,
|
||||
};
|
||||
20
frontend/src/lib/components/ui/card/card-action.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-action.svelte
Normal 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>
|
||||
15
frontend/src/lib/components/ui/card/card-content.svelte
Normal file
15
frontend/src/lib/components/ui/card/card-content.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal 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>
|
||||
23
frontend/src/lib/components/ui/card/card-header.svelte
Normal file
23
frontend/src/lib/components/ui/card/card-header.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/card/card-title.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-title.svelte
Normal 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>
|
||||
23
frontend/src/lib/components/ui/card/card.svelte
Normal file
23
frontend/src/lib/components/ui/card/card.svelte
Normal 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>
|
||||
25
frontend/src/lib/components/ui/card/index.ts
Normal file
25
frontend/src/lib/components/ui/card/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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} />
|
||||
@@ -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} />
|
||||
@@ -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} />
|
||||
13
frontend/src/lib/components/ui/collapsible/index.ts
Normal file
13
frontend/src/lib/components/ui/collapsible/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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} />
|
||||
45
frontend/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
45
frontend/src/lib/components/ui/dialog/dialog-content.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
20
frontend/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-footer.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-header.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal 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}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: DialogPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Portal {...restProps} />
|
||||
17
frontend/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
17
frontend/src/lib/components/ui/dialog/dialog-title.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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} />
|
||||
7
frontend/src/lib/components/ui/dialog/dialog.svelte
Normal file
7
frontend/src/lib/components/ui/dialog/dialog.svelte
Normal 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} />
|
||||
34
frontend/src/lib/components/ui/dialog/index.ts
Normal file
34
frontend/src/lib/components/ui/dialog/index.ts
Normal 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,
|
||||
};
|
||||
7
frontend/src/lib/components/ui/input/index.ts
Normal file
7
frontend/src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
52
frontend/src/lib/components/ui/input/input.svelte
Normal file
52
frontend/src/lib/components/ui/input/input.svelte
Normal 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}
|
||||
7
frontend/src/lib/components/ui/label/index.ts
Normal file
7
frontend/src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
20
frontend/src/lib/components/ui/label/label.svelte
Normal file
20
frontend/src/lib/components/ui/label/label.svelte
Normal 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}
|
||||
/>
|
||||
7
frontend/src/lib/components/ui/progress/index.ts
Normal file
7
frontend/src/lib/components/ui/progress/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./progress.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Progress,
|
||||
};
|
||||
27
frontend/src/lib/components/ui/progress/progress.svelte
Normal file
27
frontend/src/lib/components/ui/progress/progress.svelte
Normal 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>
|
||||
37
frontend/src/lib/components/ui/select/index.ts
Normal file
37
frontend/src/lib/components/ui/select/index.ts
Normal 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,
|
||||
};
|
||||
45
frontend/src/lib/components/ui/select/select-content.svelte
Normal file
45
frontend/src/lib/components/ui/select/select-content.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
38
frontend/src/lib/components/ui/select/select-item.svelte
Normal file
38
frontend/src/lib/components/ui/select/select-item.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/select/select-label.svelte
Normal file
20
frontend/src/lib/components/ui/select/select-label.svelte
Normal 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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: SelectPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Portal {...restProps} />
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
29
frontend/src/lib/components/ui/select/select-trigger.svelte
Normal file
29
frontend/src/lib/components/ui/select/select-trigger.svelte
Normal 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>
|
||||
11
frontend/src/lib/components/ui/select/select.svelte
Normal file
11
frontend/src/lib/components/ui/select/select.svelte
Normal 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} />
|
||||
7
frontend/src/lib/components/ui/separator/index.ts
Normal file
7
frontend/src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
21
frontend/src/lib/components/ui/separator/separator.svelte
Normal file
21
frontend/src/lib/components/ui/separator/separator.svelte
Normal 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}
|
||||
/>
|
||||
34
frontend/src/lib/components/ui/sheet/index.ts
Normal file
34
frontend/src/lib/components/ui/sheet/index.ts
Normal 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,
|
||||
};
|
||||
7
frontend/src/lib/components/ui/sheet/sheet-close.svelte
Normal file
7
frontend/src/lib/components/ui/sheet/sheet-close.svelte
Normal 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} />
|
||||
60
frontend/src/lib/components/ui/sheet/sheet-content.svelte
Normal file
60
frontend/src/lib/components/ui/sheet/sheet-content.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
20
frontend/src/lib/components/ui/sheet/sheet-footer.svelte
Normal file
20
frontend/src/lib/components/ui/sheet/sheet-footer.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/sheet/sheet-header.svelte
Normal file
20
frontend/src/lib/components/ui/sheet/sheet-header.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/sheet/sheet-overlay.svelte
Normal file
20
frontend/src/lib/components/ui/sheet/sheet-overlay.svelte
Normal 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}
|
||||
/>
|
||||
7
frontend/src/lib/components/ui/sheet/sheet-portal.svelte
Normal file
7
frontend/src/lib/components/ui/sheet/sheet-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: SheetPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Portal {...restProps} />
|
||||
17
frontend/src/lib/components/ui/sheet/sheet-title.svelte
Normal file
17
frontend/src/lib/components/ui/sheet/sheet-title.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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} />
|
||||
7
frontend/src/lib/components/ui/sheet/sheet.svelte
Normal file
7
frontend/src/lib/components/ui/sheet/sheet.svelte
Normal 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} />
|
||||
28
frontend/src/lib/components/ui/table/index.ts
Normal file
28
frontend/src/lib/components/ui/table/index.ts
Normal 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,
|
||||
};
|
||||
20
frontend/src/lib/components/ui/table/table-body.svelte
Normal file
20
frontend/src/lib/components/ui/table/table-body.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/table/table-caption.svelte
Normal file
20
frontend/src/lib/components/ui/table/table-caption.svelte
Normal 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>
|
||||
23
frontend/src/lib/components/ui/table/table-cell.svelte
Normal file
23
frontend/src/lib/components/ui/table/table-cell.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/table/table-footer.svelte
Normal file
20
frontend/src/lib/components/ui/table/table-footer.svelte
Normal 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>
|
||||
23
frontend/src/lib/components/ui/table/table-head.svelte
Normal file
23
frontend/src/lib/components/ui/table/table-head.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/table/table-header.svelte
Normal file
20
frontend/src/lib/components/ui/table/table-header.svelte
Normal 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>
|
||||
23
frontend/src/lib/components/ui/table/table-row.svelte
Normal file
23
frontend/src/lib/components/ui/table/table-row.svelte
Normal 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>
|
||||
22
frontend/src/lib/components/ui/table/table.svelte
Normal file
22
frontend/src/lib/components/ui/table/table.svelte
Normal 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>
|
||||
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal 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
13
frontend/src/lib/utils.ts
Normal 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 };
|
||||
72
frontend/src/routes/(auth)/login/+page.svelte
Normal file
72
frontend/src/routes/(auth)/login/+page.svelte
Normal 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>
|
||||
77
frontend/src/routes/(auth)/register/+page.svelte
Normal file
77
frontend/src/routes/(auth)/register/+page.svelte
Normal 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>
|
||||
20
frontend/src/routes/+layout.svelte
Normal file
20
frontend/src/routes/+layout.svelte
Normal 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>
|
||||
491
frontend/src/routes/+page.svelte
Normal file
491
frontend/src/routes/+page.svelte
Normal 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>
|
||||
158
frontend/src/routes/activity/+page.svelte
Normal file
158
frontend/src/routes/activity/+page.svelte
Normal 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>
|
||||
134
frontend/src/routes/deployments/+page.svelte
Normal file
134
frontend/src/routes/deployments/+page.svelte
Normal 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>
|
||||
121
frontend/src/routes/layout.css
Normal file
121
frontend/src/routes/layout.css
Normal 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;
|
||||
}
|
||||
}
|
||||
132
frontend/src/routes/network/+page.svelte
Normal file
132
frontend/src/routes/network/+page.svelte
Normal 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>
|
||||
60
frontend/src/routes/projects/[id]/+layout.svelte
Normal file
60
frontend/src/routes/projects/[id]/+layout.svelte
Normal 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>
|
||||
406
frontend/src/routes/projects/[id]/+page.svelte
Normal file
406
frontend/src/routes/projects/[id]/+page.svelte
Normal 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}
|
||||
192
frontend/src/routes/projects/[id]/deployments/+page.svelte
Normal file
192
frontend/src/routes/projects/[id]/deployments/+page.svelte
Normal 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}
|
||||
239
frontend/src/routes/projects/[id]/settings/+page.svelte
Normal file
239
frontend/src/routes/projects/[id]/settings/+page.svelte
Normal 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}
|
||||
105
frontend/src/routes/settings/+page.svelte
Normal file
105
frontend/src/routes/settings/+page.svelte
Normal 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>
|
||||
193
frontend/src/routes/storage/+page.svelte
Normal file
193
frontend/src/routes/storage/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user