ui: add shadcn-svelte 5 components

This commit is contained in:
ajasma6570
2025-06-18 04:21:44 +05:30
parent 581801bfce
commit caf283cbba
210 changed files with 19664 additions and 980 deletions
+1700 -42
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -17,19 +17,22 @@
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@iconify/svelte": "^5.0.0",
"@lucide/svelte": "^0.516.0",
"@internationalized/date": "^3.8.2",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"@types/tabulator-tables": "^6.2.6",
"bits-ui": "^2.8.0",
"clsx": "^2.1.1",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"mode-watcher": "^1.0.8",
"paneforge": "^1.0.0-next.5",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
@@ -45,14 +48,24 @@
"vite": "^6.2.6"
},
"dependencies": {
"@battlefieldduck/xterm-svelte": "^2.1.0",
"@fontsource/noto-sans": "^5.2.7",
"@layerstack/svelte-stores": "^1.0.2",
"@lucide/svelte": "^0.516.0",
"@svelte-put/shortcut": "^4.1.0",
"@sveltestack/svelte-query": "^1.6.0",
"adze": "^2.2.4",
"axios": "^1.10.0",
"d3-shape": "^3.2.0",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"human-format": "^1.2.1",
"is-cidr": "^5.1.1",
"is-ip": "^5.0.1",
"layerchart": "^1.0.11",
"lucide-svelte": "^0.516.0",
"svelte-i18n": "^4.0.1",
"tabulator-tables": "^6.3.1",
"validator": "^13.15.15",
"zod": "^3.25.67"
}
+111 -107
View File
@@ -1,121 +1,125 @@
@import "tailwindcss";
@import 'tailwindcss';
@import "tw-animate-css";
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--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.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
--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.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--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.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 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);
--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;
}
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@layer components {
@import './css/tabulator_simple.scss';
}
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/tabulator/6.3.1/js/tabulator.min.js" integrity="sha512-8+qwMD/110YLl5T2bPupMbPMXlARhei2mSxerb/0UWZuvcg4NjG7FdxzuuvDs2rBr/KCNqhyBDe8W3ykKB1dzA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
File diff suppressed because it is too large Load Diff
+233
View File
@@ -0,0 +1,233 @@
@use './tabulator.scss';
.tabulator {
@apply bg-primary border-none;
.tabulator-header {
@apply border-border bg-background;
.tabulator-header-contents {
@apply bg-background;
}
.tabulator-col-content {
@apply bg-background hover:bg-muted;
}
.tabulator-calcs-holder {
@apply !border-border !bg-background !border;
.tabulator-row {
@apply !bg-background;
}
}
.tabulator-col {
@apply bg-background;
&.tabulator-sortable {
@media (hover: hover) and (pointer: fine) {
@apply border-border;
&.tabulator-col-sorter-element:hover {
@apply bg-background cursor-pointer;
}
}
}
}
.tabulator-col-title-holder {
@apply text-black dark:text-white;
.tabulator-col-title {
@apply text-black dark:text-white;
}
}
.tabulator-col-title input {
@apply border-border border;
}
}
.tabulator-row {
@apply border-border bg-background border-b text-black dark:text-white;
@media (hover: hover) and (pointer: fine) {
&.tabulator-selectable:hover {
@apply bg-background;
cursor: pointer;
}
}
.tabulator-cell {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
&:last-of-type {
border-right: none;
}
&.tabulator-row-header {
border-bottom: none;
}
.tabulator-data-tree-control {
@apply border-primary bg-primary;
.tabulator-data-tree-control-collapse {
background: transparent;
&:after {
@apply bg-primary-foreground;
}
}
.tabulator-data-tree-control-expand {
@apply bg-primary-foreground;
&:after {
@apply bg-primary-foreground;
}
}
}
}
&.tabulator-group {
span {
color: #666;
}
}
.tabulator-frozen input {
@apply opacity-40;
background-color: rgb(63 63 70) !important;
@apply border-border border;
}
}
.tabulator-tableholder {
.tabulator-cell {
.tabulator-data-tree-control {
@apply bg-primary;
}
}
@apply bg-background;
.tabulator-placeholder {
span {
@apply text-secondary;
}
.tabulator-placeholder-contents {
@apply text-secondary;
}
}
}
.tabulator-footer {
@apply border-border bg-background;
.tabulator-footer-contents {
@apply text-black dark:text-white;
.tabulator-paginator {
label {
@apply text-black dark:text-white;
}
select {
@apply border-border bg-primary text-black dark:text-white;
}
.tabulator-page {
@apply border-border bg-primary-foreground hover:border-primary/10 hover:bg-primary/10 dark:bg-muted hover:dark:border-muted hover:dark:bg-muted/10 text-black dark:text-white;
&.active {
@apply !bg-primary !text-secondary;
}
&:disabled {
opacity: 0.4;
}
}
}
}
.tabulator-calcs-holder {
.tabulator-row {
@apply !bg-primary;
}
}
.tabulator-spreadsheet-tabs {
.tabulator-spreadsheet-tab {
font-weight: normal;
&.tabulator-spreadsheet-tab-active {
color: tabulator.$footerActiveColor;
font-weight: bold;
}
}
}
}
}
.tabulator-table .tabulator-row-odd {
@apply border-border bg-background border-b border-none text-black dark:text-white;
.tabulator-frozen {
@apply bg-secondary;
}
@media (hover: hover) and (pointer: fine) {
&.tabulator-selectable:hover {
// @apply bg-background hover:bg-muted;
cursor: pointer;
}
}
}
.tabulator-table .tabulator-row-even {
@apply border-border bg-background border-b border-none text-black dark:text-white;
.tabulator-frozen {
@apply bg-secondary;
}
.tabulator-frozen input {
@apply border-border border;
}
@media (hover: hover) and (pointer: fine) {
&.tabulator-selectable:hover {
// @apply bg-background hover:bg-muted;
cursor: pointer;
}
}
}
.tabulator-table .tabulator-selected {
@apply !bg-muted;
}
.tabulator-table .tabulator-tree-level-0 .tabulator-cell:first-of-type {
padding-left: 9px;
}
.tabulator-table .tabulator-tree-level-1 .tabulator-cell:first-of-type {
padding-left: 5px;
}
.tabulator-table .tabulator-tree-level-2 .tabulator-cell:first-of-type {
padding-left: 12px;
}
.tabulator-table .tabulator-tree-level-3 .tabulator-cell:first-of-type {
padding-left: 12px;
}
.tabulator-print-table {
.tabulator-print-table-group {
span {
margin-left: 10px;
color: #666;
}
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* SPDX-License-Identifier: BSD-2-Clause
*
* Copyright (c) 2025 The FreeBSD Foundation.
*
* This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
* of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
* under sponsorship from the FreeBSD Foundation.
*/
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { store as token } from '$lib/stores/auth';
import adze from 'adze';
import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios';
import { toast } from "svelte-sonner";
import { get } from 'svelte/store';
export let ENDPOINT: string;
export let API_ENDPOINT: string;
if (browser) {
ENDPOINT = window.location.origin;
API_ENDPOINT = `${window.location.origin}/api`;
} else {
ENDPOINT = '';
API_ENDPOINT = '';
}
export const api: AxiosInstance = axios.create({
baseURL: API_ENDPOINT
});
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
if (browser) {
if (get(token)) {
config.headers['Authorization'] = `Bearer ${get(token)}`;
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401 && browser) {
toast.error('Session expired, please login again', {
position: 'bottom-center'
});
goto('/login');
return;
}
handleAxiosError(error);
return Promise.reject(error);
}
);
export function handleAxiosError(error: unknown): void {
if (!browser) return;
if (!axios.isAxiosError(error)) {
toast.error('An unexpected error occurred', {
position: 'bottom-center'
});
adze.withEmoji.error('An unexpected error occurred');
return;
}
const axiosError = error as AxiosError<{ message?: string }>;
if (axiosError.response) {
const errorMessage =
axiosError.response.data?.message || axiosError.message || 'An error occurred';
// adze.withEmoji.error('Status:', axiosError.response.status);
// adze.withEmoji.error('Data:', axiosError.response.data);
// adze.withEmoji.error('Error message:', errorMessage);
// showToast({ text: errorMessage, type: 'error', timeout: 5000 });
} else if (axiosError.request) {
// adze.withEmoji.error('No response:', axiosError.request);
// showToast({
// text: 'No response from server',
// type: 'error',
// timeout: 5000
// });
}
}
+33
View File
@@ -0,0 +1,33 @@
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import { DiskSchema, type Disk } from '$lib/types/disk/disk';
import { apiRequest } from '$lib/utils/http';
import { z } from 'zod/v4';
export async function listDisks(): Promise<Disk[]> {
return await apiRequest('/disk/list', z.array(DiskSchema), 'GET');
}
export async function destroyDisk(disk: string): Promise<APIResponse> {
return await apiRequest(`/disk/wipe`, APIResponseSchema, 'POST', {
device: disk
});
}
export async function destroyPartition(partition: string): Promise<APIResponse> {
return await apiRequest(`/disk/delete-partition`, APIResponseSchema, 'POST', {
device: partition
});
}
export async function initializeGPT(disk: string): Promise<APIResponse> {
return await apiRequest(`/disk/initialize-gpt`, APIResponseSchema, 'POST', {
device: disk
});
}
export async function createPartitions(disk: string, sizes: number[]): Promise<APIResponse> {
return await apiRequest(`/disk/create-partitions`, APIResponseSchema, 'POST', {
device: disk,
sizes
});
}
+41
View File
@@ -0,0 +1,41 @@
import { AuditLogSchema, type AuditLog } from '$lib/types/info/audit';
import { apiRequest } from '$lib/utils/http';
import { getTranslation } from '$lib/utils/i18n';
import { capitalizeFirstLetter } from '$lib/utils/string';
export async function getAuditLogs(): Promise<AuditLog> {
return await apiRequest('/info/audit-logs', AuditLogSchema, 'GET');
}
export function formatAction(action: string): string {
if (action.includes('|-|')) {
const parts = action.split('|-|');
return capitalizeFirstLetter(getTranslation(parts[0], parts[0]), true) + ' ' + parts[1];
}
switch (action) {
case 'login':
return getTranslation('auth.login', 'Login');
case 'revoke_token':
return getTranslation('auth.logout', 'Logout');
default:
return action;
}
}
export function formatStatus(status: string): string {
switch (status) {
case 'started':
return 'Started';
case 'success':
return 'OK';
case 'failure':
return 'Failed';
case 'failed':
return 'Failed';
case 'progress':
return 'In Progress';
default:
return status;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { BasicInfoSchema, type BasicInfo } from '$lib/types/info/basic';
import { apiRequest } from '$lib/utils/http';
export async function getBasicInfo(): Promise<BasicInfo> {
return await apiRequest('/info/basic', BasicInfoSchema, 'GET');
}
+20
View File
@@ -0,0 +1,20 @@
import {
CPUInfoHistoricalSchema,
CPUInfoSchema,
type CPUInfo,
type CPUInfoHistorical
} from '$lib/types/info/cpu';
import { apiRequest } from '$lib/utils/http';
import type { QueryFunctionContext } from '@sveltestack/svelte-query';
export async function getCPUInfo(
queryObj?: QueryFunctionContext
): Promise<CPUInfo | CPUInfoHistorical> {
if (queryObj) {
if (queryObj.queryKey.includes('cpuInfoHistorical')) {
return await apiRequest('/info/cpu/historical', CPUInfoHistoricalSchema, 'GET');
}
}
return await apiRequest('/info/cpu', CPUInfoSchema, 'GET');
}
View File
+41
View File
@@ -0,0 +1,41 @@
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import { NoteSchema, NotesSchema, type Note, type Notes } from '$lib/types/info/notes';
import { apiRequest } from '$lib/utils/http';
import { z } from 'zod/v4';
async function notesRequest(
endpoint: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
body?: object
): Promise<Notes | Note | APIResponse> {
let schema;
if (method === 'GET') {
schema = z.array(NoteSchema);
} else if (method === 'POST') {
schema = NoteSchema;
} else {
schema = APIResponseSchema;
}
return await apiRequest(endpoint, schema, method, body);
}
export const getNotes = () => notesRequest('/info/notes', 'GET');
export const deleteNote = (id: number) => notesRequest(`/info/notes/${id}`, 'DELETE');
export const createNote = async (title: string, content: string): Promise<Note | APIResponse> => {
return (await notesRequest('/info/notes', 'POST', { title, content })) as Note | APIResponse;
};
export const updateNote = async (
id: number,
title: string,
content: string
): Promise<APIResponse> => {
return (await notesRequest(`/info/notes/${id}`, 'PUT', { title, content })) as APIResponse;
};
export const deleteNotes = async (ids: number[]): Promise<APIResponse> => {
return (await notesRequest('/info/notes/bulk-delete', 'POST', { ids })) as APIResponse;
};
+10
View File
@@ -0,0 +1,10 @@
import { RAMInfoSchema, type RAMInfo } from '$lib/types/info/ram';
import { apiRequest } from '$lib/utils/http';
export async function getRAMInfo(): Promise<RAMInfo> {
return await apiRequest('/info/ram', RAMInfoSchema, 'GET');
}
export async function getSwapInfo(): Promise<RAMInfo> {
return await apiRequest('/info/swap', RAMInfoSchema, 'GET');
}
+6
View File
@@ -0,0 +1,6 @@
import { IfaceSchema, type Iface } from '$lib/types/network/iface';
import { apiRequest } from '$lib/utils/http';
export async function getInterfaces(): Promise<Iface[]> {
return await apiRequest('/network/interface', IfaceSchema.array(), 'GET');
}
+67
View File
@@ -0,0 +1,67 @@
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import { SwitchListSchema, type SwitchList } from '$lib/types/network/switch';
import { apiRequest } from '$lib/utils/http';
export async function getSwitches(): Promise<SwitchList> {
return await apiRequest('/network/switch', SwitchListSchema, 'GET');
}
export async function createSwitch(
name: string,
mtu: number,
vlan: number,
address: string,
address6: string,
privateSw: boolean,
dhcp: boolean,
ports: string[],
disableIPv6: boolean,
slaac: boolean
): Promise<APIResponse> {
const body = {
name,
mtu,
vlan,
address,
address6,
private: privateSw,
ports,
dhcp,
disableIPv6,
slaac
};
return await apiRequest('/network/switch/standard', APIResponseSchema, 'POST', body);
}
export async function deleteSwitch(id: number): Promise<APIResponse> {
return await apiRequest(`/network/switch/standard/${id}`, APIResponseSchema, 'DELETE');
}
export async function updateSwitch(
id: number,
mtu: number,
vlan: number,
address: string,
address6: string,
privateSw: boolean,
ports: string[],
disableIPv6: boolean,
slaac: boolean,
dhcp: boolean
): Promise<APIResponse> {
const body = {
id,
mtu,
vlan,
address,
address6,
private: privateSw,
ports,
disableIPv6,
slaac,
dhcp
};
return await apiRequest('/network/switch/standard', APIResponseSchema, 'PUT', body);
}
+24
View File
@@ -0,0 +1,24 @@
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import {
PCIDeviceSchema,
PPTDeviceSchema,
type PCIDevice,
type PPTDevice
} from '$lib/types/system/pci';
import { apiRequest } from '$lib/utils/http';
export async function getPCIDevices(): Promise<PCIDevice[]> {
return await apiRequest('/system/pci-devices', PCIDeviceSchema.array(), 'GET');
}
export async function getPPTDevices(): Promise<PPTDevice[]> {
return await apiRequest('/system/ppt-devices', PPTDeviceSchema.array(), 'GET');
}
export async function addPPTDevice(domain: string, deviceID: string): Promise<APIResponse> {
return await apiRequest('/system/ppt-devices', APIResponseSchema, 'POST', { domain, deviceID });
}
export async function removePPTDevice(deviceID: string): Promise<APIResponse> {
return await apiRequest(`/system/ppt-devices/${deviceID}`, APIResponseSchema, 'DELETE');
}
@@ -0,0 +1,30 @@
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import { DownloadSchema, type Download } from '$lib/types/utilities/downloader';
import { apiRequest } from '$lib/utils/http';
export async function getDownloads(): Promise<Download[]> {
return await apiRequest('/utilities/downloads', DownloadSchema.array(), 'GET');
}
export async function startDownload(url: string): Promise<APIResponse> {
return await apiRequest('/utilities/downloads', APIResponseSchema, 'POST', {
url
});
}
export async function deleteDownload(id: number): Promise<APIResponse> {
return await apiRequest(`/utilities/downloads/${id}`, APIResponseSchema, 'DELETE');
}
export async function bulkDeleteDownloads(ids: number[]): Promise<APIResponse> {
return await apiRequest('/utilities/downloads/bulk-delete', APIResponseSchema, 'POST', {
ids
});
}
export async function getSignedURL(name: string, parentUUID: string): Promise<APIResponse> {
return await apiRequest('/utilities/downloads/signed-url', APIResponseSchema, 'POST', {
name,
parentUUID
});
}
+9
View File
@@ -0,0 +1,9 @@
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import { apiRequest } from '$lib/utils/http';
export async function storageDetach(vmId: number, storageId: number): Promise<APIResponse> {
return await apiRequest(`/vm/storage/detach`, APIResponseSchema, 'POST', {
vmId,
storageId
});
}
+69
View File
@@ -0,0 +1,69 @@
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import {
VMDomainSchema,
VMSchema,
VMStatSchema,
type CreateData,
type VM,
type VMDomain,
type VMStat
} from '$lib/types/vm/vm';
import { apiRequest } from '$lib/utils/http';
import { z } from 'zod/v4';
export async function getVMs(): Promise<VM[]> {
return await apiRequest('/vm', z.array(VMSchema), 'GET');
}
export async function newVM(data: CreateData): Promise<APIResponse> {
return await apiRequest('/vm', APIResponseSchema, 'POST', {
name: data.name,
vmId: data.id,
iso: data.storage.iso,
storageType: data.storage.type,
storageDataset: data.storage.guid,
storageSize: data.storage.size,
storageEmulationType: data.storage.emulation,
switchId: data.network.switch,
switchEmulationType: data.network.emulation,
macAddress: data.network.mac,
cpuSockets: data.hardware.sockets,
cpuCores: data.hardware.cores,
cpuThreads: data.hardware.threads,
ram: data.hardware.memory,
vncPort: data.advanced.vncPort,
vncPassword: data.advanced.vncPassword,
vncWait: data.advanced.vncWait,
vncResolution: data.advanced.vncResolution,
startAtBoot: data.advanced.startAtBoot,
bootOrder: data.advanced.bootOrder,
pciDevices: data.hardware.passthroughIds,
description: data.description
});
}
export async function deleteVM(id: number): Promise<APIResponse> {
return await apiRequest(`/vm/${id}`, APIResponseSchema, 'DELETE');
}
export async function getVMDomain(id: number | string): Promise<VMDomain> {
return await apiRequest(`/vm/domain/${id}`, VMDomainSchema, 'GET');
}
export async function actionVm(id: number | string, action: string): Promise<APIResponse> {
return await apiRequest(`/vm/${id}/${action}`, APIResponseSchema, 'POST');
}
export async function getStats(vmId: number, limit: number): Promise<VMStat[]> {
return await apiRequest(`/vm/stats`, z.array(VMStatSchema), 'POST', {
vmId,
limit
});
}
export async function updateDescription(id: number, description: string): Promise<APIResponse> {
return await apiRequest(`/vm/description`, APIResponseSchema, 'PUT', {
id,
description
});
}
+113
View File
@@ -0,0 +1,113 @@
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import {
DatasetSchema,
PeriodicSnapshotSchema,
type Dataset,
type PeriodicSnapshot
} from '$lib/types/zfs/dataset';
import { apiRequest } from '$lib/utils/http';
export async function getDatasets(): Promise<Dataset[]> {
return await apiRequest('/zfs/datasets', DatasetSchema.array(), 'GET');
}
export async function deleteSnapshot(
snapshot: Dataset,
recursive: boolean = false
): Promise<APIResponse> {
const param = recursive ? '?recursive=true' : '';
return await apiRequest(
`/zfs/datasets/snapshot/${snapshot.properties.guid}${param}`,
APIResponseSchema,
'DELETE'
);
}
export async function createSnapshot(
dataset: Dataset,
name: string,
recursive: boolean
): Promise<APIResponse> {
return await apiRequest('/zfs/datasets/snapshot', APIResponseSchema, 'POST', {
name: name,
recursive: recursive,
guid: dataset.properties.guid
});
}
export async function getPeriodicSnapshots(): Promise<PeriodicSnapshot[]> {
return await apiRequest('/zfs/datasets/snapshot/periodic', PeriodicSnapshotSchema.array(), 'GET');
}
export async function createPeriodicSnapshot(
dataset: Dataset,
prefix: string,
recursive: boolean,
interval: number
): Promise<APIResponse> {
return await apiRequest('/zfs/datasets/snapshot/periodic', APIResponseSchema, 'POST', {
guid: dataset.properties.guid,
prefix: prefix,
recursive: recursive,
interval: interval
});
}
export async function deletePeriodicSnapshot(guid: string): Promise<APIResponse> {
return await apiRequest(`/zfs/datasets/snapshot/periodic/${guid}`, APIResponseSchema, 'DELETE');
}
export async function createFileSystem(
name: string,
parent: string,
properties: Record<string, string>
): Promise<APIResponse> {
return await apiRequest('/zfs/datasets/filesystem', APIResponseSchema, 'POST', {
name: name,
parent: parent,
properties: properties
});
}
export async function deleteFileSystem(dataset: Dataset): Promise<APIResponse> {
return await apiRequest(
`/zfs/datasets/filesystem/${dataset.properties.guid}`,
APIResponseSchema,
'DELETE'
);
}
export async function rollbackSnapshot(guid: string): Promise<APIResponse> {
return await apiRequest(`/zfs/datasets/snapshot/rollback`, APIResponseSchema, 'POST', {
guid: guid,
destroyMoreRecent: true
});
}
export async function createVolume(
name: string,
parent: string,
props: Record<string, string>
): Promise<APIResponse> {
return await apiRequest('/zfs/datasets/volume', APIResponseSchema, 'POST', {
name: name,
parent: parent,
properties: props
});
}
export async function deleteVolume(dataset: Dataset): Promise<APIResponse> {
return await apiRequest(
`/zfs/datasets/volume/${dataset.properties.guid}`,
APIResponseSchema,
'DELETE'
);
}
export async function bulkDelete(datasets: Dataset[]): Promise<APIResponse> {
const guids = datasets.map((dataset) => dataset.properties.guid);
return await apiRequest('/zfs/datasets/bulk-delete', APIResponseSchema, 'POST', {
guids: guids
});
}
+77
View File
@@ -0,0 +1,77 @@
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import {
IODelayHistoricalSchema,
IODelaySchema,
PoolStatPointsResponseSchema,
ZpoolSchema,
type CreateZpool,
type IODelay,
type IODelayHistorical,
type PoolStatPointsResponse,
type ReplaceDevice,
type Zpool
} from '$lib/types/zfs/pool';
import { apiRequest } from '$lib/utils/http';
import type { QueryFunctionContext } from '@sveltestack/svelte-query';
export async function getIODelay(
queryObj: QueryFunctionContext | undefined
): Promise<IODelay | IODelayHistorical> {
if (queryObj) {
if (queryObj.queryKey.includes('ioDelayHistorical')) {
const data = await apiRequest(
'/zfs/pool/io-delay/historical',
IODelayHistoricalSchema,
'GET'
);
return IODelayHistoricalSchema.parse(data);
}
}
return await apiRequest('/zfs/pool/io-delay', IODelaySchema, 'GET');
}
export async function getPools(): Promise<Zpool[]> {
return await apiRequest('/zfs/pools', ZpoolSchema.array(), 'GET');
}
export async function createPool(data: CreateZpool) {
return await apiRequest('/zfs/pools', APIResponseSchema, 'POST', {
...data
});
}
export async function replaceDevice(data: ReplaceDevice) {
return await apiRequest(`/zfs/pools/${data.name}/replace-device`, APIResponseSchema, 'POST', {
...data
});
}
export async function deletePool(name: string) {
return await apiRequest(`/zfs/pools/${name}`, APIResponseSchema, 'DELETE');
}
export async function scrubPool(name: string) {
return await apiRequest(`/zfs/pools/${name}/scrub`, APIResponseSchema, 'POST');
}
export async function getPoolStats(
interval: number,
limit: number
): Promise<PoolStatPointsResponse> {
return await apiRequest(
`/zfs/pool/stats/${interval}/${limit}`,
PoolStatPointsResponseSchema,
'GET'
);
}
export async function editPool(
name: string,
properties: Record<string, string>
): Promise<APIResponse> {
return await apiRequest(`/zfs/pools`, APIResponseSchema, 'PATCH', {
name,
properties
});
}
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import { getTranslation } from '$lib/utils/i18n';
interface Props {
open: boolean;
names: {
parent: string;
element: string;
};
actions: {
onConfirm: () => void;
onCancel: () => void;
};
customTitle?: string;
}
let { open, names, actions, customTitle }: Props = $props();
</script>
<AlertDialog.Root bind:open>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{getTranslation('are_you_sure', 'Are you sure?')}</AlertDialog.Title>
<AlertDialog.Description>
{#if customTitle}
{@html customTitle}
{:else}
{getTranslation(
'common.permanent_delete_msg',
'This action cannot be undone. This will permanently delete'
)}
{names.parent} <span class="font-semibold">{names.element}</span>.
{/if}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={actions.onCancel}>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={actions.onConfirm}>Continue</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,92 @@
<script lang="ts">
import type { PieChartData, SeriesDataWithBaseline } from '$lib/types/common';
import { scaleBand, scaleLinear } from 'd3-scale';
import { format } from 'date-fns';
import humanFormat from 'human-format';
import { Axis, BarChart, Highlight, Tooltip } from 'layerchart';
type Colors = {
baseline: string;
value: string;
};
interface Data {
containerClass: string;
data: SeriesDataWithBaseline[];
// formatter?: 'size-formatter' | 'default';
colors: Colors;
}
const { containerClass, data, colors }: Data = $props();
</script>
<div class={containerClass}>
<BarChart
{data}
x="name"
cDomain={[]}
cRange={[colors.baseline, colors.value]}
series={[
{ key: 'baseline', color: colors.baseline, props: { insets: { x: 8 } } },
{
key: 'value',
color: colors.value,
props: { insets: { x: 8 } }
}
]}
xScale={scaleBand().padding(0.2)}
yScale={scaleLinear()}
renderContext={'svg'}
padding={{ bottom: 50, left: 20 }}
>
<svelte:fragment slot="axis">
<Axis
placement="left"
labelProps={{ class: 'fill-green-500 stroke-none' }}
tickLabelProps={{
class: 'fill-neutral-300 dark:fill-neutral-200 stroke-none'
}}
format={(d) => humanFormat(d)}
rule={{
class: 'stroke-border dark:stroke-border'
}}
ticks={2}
/>
<Axis
placement="bottom"
tickLength={5}
ticks={1}
rule={{
class: 'stroke-border dark:stroke-border '
}}
tickLabelProps={{
rotate: 337,
textAnchor: 'end',
class: 'fill-neutral-300 dark:fill-neutral-200 stroke-none'
}}
/>
</svelte:fragment>
<svelte:fragment slot="highlight">
<Highlight
area={{
class: 'fill-neutral-900/50 dark:fill-neutral-900/60'
}}
/>
</svelte:fragment>
<svelte:fragment slot="tooltip">
<Tooltip.Root class="bg-secondary" let:data>
<Tooltip.Header class={'border-b border-neutral-200 dark:border-neutral-700'}
>{data.name}</Tooltip.Header
>
<Tooltip.List>
<Tooltip.Item
label="baseline"
value={humanFormat(data.baseline)}
color={colors.baseline}
/>
<Tooltip.Item label="value" value={humanFormat(data.value)} color={colors.value} />
</Tooltip.List>
</Tooltip.Root>
</svelte:fragment>
</BarChart>
</div>
@@ -0,0 +1,112 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import CustomCheckbox from '$lib/components/ui/custom-input/checkbox.svelte';
import CustomComboBox from '$lib/components/ui/custom-input/combobox.svelte';
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import { generatePassword } from '$lib/utils/string';
import Icon from '@iconify/svelte';
import { onMount } from 'svelte';
interface Props {
vncPort: number;
vncPassword: string;
vncWait: boolean;
vncResolution: string;
startAtBoot: boolean;
bootOrder: number;
}
let {
vncPort = $bindable(),
vncPassword = $bindable(),
vncWait = $bindable(),
vncResolution = $bindable(),
startAtBoot = $bindable(),
bootOrder = $bindable()
}: Props = $props();
onMount(() => {
vncPort = Math.floor(Math.random() * (5999 - 5900 + 1)) + 5900;
});
let resolutionOpen = $state(false);
const resolutions = [
{ label: '1024x768', value: '1024x768' },
{ label: '1280x720', value: '1280x720' },
{ label: '1920x1080', value: '1920x1080' },
{ label: '2560x1440', value: '2560x1440' },
{ label: '3840x2160', value: '3840x2160' }
];
</script>
<div class="flex flex-col gap-4 p-4">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<CustomValueInput
label="VNC Port"
placeholder="5900"
bind:value={vncPort}
classes="flex-1 space-y-1"
/>
<div class="space-y-1">
<Label class="w-24 whitespace-nowrap text-sm">VNC Password</Label>
<div class="flex w-full max-w-sm items-center space-x-2">
<Input
type="password"
id="d-passphrase"
placeholder="Enter or generate passphrase"
class="w-full"
autocomplete="off"
bind:value={vncPassword}
showPasswordOnFocus={true}
/>
<Button
onclick={() => {
vncPassword = generatePassword();
}}
>
<Icon
icon="fad:random-2dice"
class="h-6 w-6"
onclick={() => {
vncPassword = generatePassword();
}}
/>
</Button>
</div>
</div>
</div>
<CustomComboBox
bind:open={resolutionOpen}
label="VNC Resolution"
bind:value={vncResolution}
data={resolutions}
classes="flex-1 space-y-1"
placeholder="Select VNC resolution"
width="w-[85%]"
></CustomComboBox>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<CustomCheckbox label="VNC Wait" bind:checked={vncWait} classes="flex items-center gap-2"
></CustomCheckbox>
<CustomCheckbox
label="Start On Boot"
bind:checked={startAtBoot}
classes="flex items-center gap-2"
></CustomCheckbox>
<CustomValueInput
label="Startup/Shutdown Order"
placeholder="0"
type="number"
bind:value={bootOrder}
classes="flex-1 space-y-1"
/>
</div>
</div>
@@ -0,0 +1,39 @@
<script lang="ts">
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
interface Props {
name: string;
id: number;
description: string;
}
let { name = $bindable(), id = $bindable(), description = $bindable() }: Props = $props();
</script>
<div class="flex flex-col gap-4 p-4">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<CustomValueInput
label="VM Name"
placeholder="my-virtual-machine"
bind:value={name}
classes="flex-1 space-y-1"
/>
<CustomValueInput
label="VM ID"
placeholder="100"
type="number"
bind:value={id}
classes="flex-1 space-y-1"
/>
</div>
<CustomValueInput
label="Description"
placeholder="Optional description for this virtual machine"
type="textarea"
textAreaCLasses="min-h-28"
bind:value={description}
classes="flex-1 space-y-1"
/>
</div>
@@ -0,0 +1,298 @@
<script lang="ts">
import { getInterfaces } from '$lib/api/network/iface';
import { getSwitches } from '$lib/api/network/switch';
import { getPCIDevices, getPPTDevices } from '$lib/api/system/pci';
import { getDownloads } from '$lib/api/utilities/downloader';
import { newVM } from '$lib/api/vm/vm';
import { getDatasets } from '$lib/api/zfs/datasets';
import { getPools } from '$lib/api/zfs/pool';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import type { SwitchList } from '$lib/types/network/switch';
import type { PCIDevice, PPTDevice } from '$lib/types/system/pci';
import type { Download } from '$lib/types/utilities/downloader';
import type { Dataset } from '$lib/types/zfs/dataset';
import { getTranslation } from '$lib/utils/i18n';
import { capitalizeFirstLetter, generatePassword } from '$lib/utils/string';
import { isValidCreateData } from '$lib/utils/vm/vm';
import Icon from '@iconify/svelte';
import { useQueries } from '@sveltestack/svelte-query';
import Advanced from './Advanced.svelte';
import Basic from './Basic.svelte';
import Hardware from './Hardware.svelte';
import Network from './Network.svelte';
import Storage from './Storage.svelte';
import { type CreateData } from '$lib/types/vm/vm';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
interface Props {
open: boolean;
}
let { open = $bindable() }: Props = $props();
const results = useQueries([
{
queryKey: ['poolList-svm'],
queryFn: async () => {
return await getPools();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: [],
refetchOnMount: 'always'
},
{
queryKey: ['datasetList-svm'],
queryFn: async () => {
return await getDatasets();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: [],
refetchOnMount: 'always'
},
{
queryKey: ['networkInterfaces-svm'],
queryFn: async () => {
return await getInterfaces();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: [],
refetchOnMount: 'always'
},
{
queryKey: ['networkSwitches-svm'],
queryFn: async () => {
return await getSwitches();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: {} as SwitchList,
refetchOnMount: 'always'
},
{
queryKey: ['pciDevices-svm'],
queryFn: async () => {
return (await getPCIDevices()) as PCIDevice[];
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: [] as PCIDevice[],
refetchOnMount: 'always'
},
{
queryKey: ['pptDevices-svm'],
queryFn: async () => {
return (await getPPTDevices()) as PPTDevice[];
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: [] as PPTDevice[],
refetchOnMount: 'always'
},
{
queryKey: ['downloads-svm'],
queryFn: async () => {
return await getDownloads();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: [],
refetchOnMount: 'always'
}
]);
let datasets: Dataset[] = $derived($results[1].data as Dataset[]);
let volumes: Dataset[] = $derived(datasets.filter((dataset) => dataset.type === 'volume'));
let filesystems: Dataset[] = $derived(
datasets.filter((dataset) => dataset.type === 'filesystem')
);
let networkSwitches: SwitchList = $derived($results[3].data as SwitchList);
let pciDevices: PCIDevice[] = $derived($results[4].data as PCIDevice[]);
let pptDevices: PPTDevice[] = $derived($results[5].data as PPTDevice[]);
let passablePci: PCIDevice[] = $derived(
pciDevices.filter((device) => device.name.startsWith('ppt'))
);
let downloads = $derived($results[6].data as Download[]);
const tabs = [
{ value: 'basic', label: 'Basic' },
{ value: 'storage', label: 'Storage' },
{ value: 'network', label: 'Network' },
{ value: 'hardware', label: 'Hardware' },
{ value: 'advanced', label: 'Advanced' }
];
let modal: CreateData = $state({
name: '',
id: 0,
description: '',
storage: {
type: 'zvol',
guid: '',
size: 0,
emulation: 'ahci-hd',
iso: ''
},
network: {
switch: 0,
mac: '',
emulation: 'e1000'
},
hardware: {
sockets: 1,
cores: 1,
threads: 1,
memory: 0,
passthroughIds: [] as number[]
},
advanced: {
vncPort: 0,
vncPassword: generatePassword(),
vncWait: false,
vncResolution: '1024x768',
startAtBoot: false,
bootOrder: 0
}
});
async function create() {
const data = $state.snapshot(modal);
if (isValidCreateData(data)) {
const response = await newVM(data);
if (response.status === 'success') {
toast.success(`Created VM ${modal.name}`, {
duration: 3000,
position: 'bottom-center'
});
open = false;
} else {
toast.error('Failed to create VM', {
duration: 3000,
position: 'bottom-center'
});
}
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content
class="fixed left-1/2 top-1/2 max-h-[90vh] w-full max-w-[90%] -translate-x-1/2 -translate-y-1/2 transform overflow-visible overflow-y-auto p-5 transition-all duration-300 ease-in-out lg:max-w-xl"
>
<div class="flex items-center justify-between px-2 py-3">
<Dialog.Header class="p-0">
<Dialog.Title class="flex flex-col gap-1 text-left">
<div class="flex items-center gap-2">
<Icon icon="material-symbols:monitor-outline-rounded" class="h-5 w-5 " />
Create Virtual Machine
</div>
<p class="text-muted-foreground text-sm">
Configure your virtual machine with custom hardware and network settings
</p>
</Dialog.Title>
</Dialog.Header>
<div class="flex items-center gap-0.5">
<Button
size="sm"
variant="ghost"
class="h-8"
title={capitalizeFirstLetter(getTranslation('common.reset', 'Reset'))}
>
<Icon icon="radix-icons:reset" class="pointer-events-none h-4 w-4" />
<span class="sr-only"
>{capitalizeFirstLetter(getTranslation('common.reset', 'Reset'))}</span
>
</Button>
<Button
size="sm"
variant="ghost"
class="h-8"
onclick={() => (open = false)}
title={capitalizeFirstLetter(getTranslation('common.close', 'Close'))}
>
<Icon icon="material-symbols:close-rounded" class="pointer-events-none h-4 w-4" />
<span class="sr-only"
>{capitalizeFirstLetter(getTranslation('common.close', 'Close'))}</span
>
</Button>
</div>
</div>
<Tabs.Root value="basic" class="w-full overflow-hidden">
<Tabs.List class="grid w-full grid-cols-5 p-0 px-2">
{#each tabs as { value, label }}
<Tabs.Trigger class="border-b" {value}>{label}</Tabs.Trigger>
{/each}
</Tabs.List>
{#each tabs as { value, label }}
<Tabs.Content {value} class="">
<div class="">
{#if value === 'basic'}
<Basic
bind:name={modal.name}
bind:id={modal.id}
bind:description={modal.description}
/>
{:else if value === 'storage'}
<Storage
{volumes}
{filesystems}
{downloads}
bind:type={modal.storage.type}
bind:guid={modal.storage.guid}
bind:size={modal.storage.size}
bind:emulation={modal.storage.emulation}
bind:iso={modal.storage.iso}
/>
{:else if value === 'network'}
<Network
switches={networkSwitches}
bind:switch={modal.network.switch}
bind:mac={modal.network.mac}
bind:emulation={modal.network.emulation}
/>
{:else if value === 'hardware'}
<Hardware
devices={passablePci}
{pptDevices}
bind:sockets={modal.hardware.sockets}
bind:cores={modal.hardware.cores}
bind:threads={modal.hardware.threads}
bind:memory={modal.hardware.memory}
bind:passthroughIds={modal.hardware.passthroughIds}
/>
{:else if value === 'advanced'}
<Advanced
bind:vncPort={modal.advanced.vncPort}
bind:vncPassword={modal.advanced.vncPassword}
bind:vncWait={modal.advanced.vncWait}
bind:startAtBoot={modal.advanced.startAtBoot}
bind:bootOrder={modal.advanced.bootOrder}
bind:vncResolution={modal.advanced.vncResolution}
/>
{/if}
</div>
</Tabs.Content>
{/each}
</Tabs.Root>
<Dialog.Footer>
<div class="flex w-full justify-end px-1 py-3 md:flex-row">
<Button size="sm" type="button" class="h-8" onclick={() => create()}
>Create Virtual Machine</Button
>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,119 @@
<script lang="ts">
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
import { Label } from '$lib/components/ui/label/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import type { PCIDevice, PPTDevice } from '$lib/types/system/pci';
import { getPCIDeviceId } from '$lib/utils/system/pci';
import humanFormat from 'human-format';
interface Props {
sockets: number;
cores: number;
threads: number;
memory: number;
devices: PCIDevice[];
pptDevices: PPTDevice[];
passthroughIds: number[];
}
let {
sockets = $bindable(),
cores = $bindable(),
threads = $bindable(),
memory = $bindable(),
devices = $bindable(),
pptDevices = $bindable(),
passthroughIds = $bindable()
}: Props = $props();
let humanSize = $state('1024 M');
$effect(() => {
try {
const p = humanFormat.parse.raw(humanSize);
memory = p.factor * p.value;
} catch {
memory = 1024;
}
});
let checkboxItems = $derived.by(() =>
devices.map((device) => {
const raw = getPCIDeviceId(device)
.replace(/pci\d+:/, '')
.replace(/:/g, '/');
const existing = pptDevices.find((p) => p.deviceID === raw);
return { device, pptId: existing?.id.toString() ?? raw, deviceId: raw };
})
);
let selectedPptIds = $state<string[]>([]);
function toggle(id: string, on: boolean) {
selectedPptIds = on ? [...selectedPptIds, id] : selectedPptIds.filter((x) => x !== id);
passthroughIds = selectedPptIds.map((x) => parseInt(x));
}
</script>
<div class="flex flex-col gap-4 p-4">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<CustomValueInput
label="CPU Sockets"
placeholder="1"
type="number"
bind:value={sockets}
classes="flex-1"
/>
<CustomValueInput
label="CPU Cores"
placeholder="1"
type="number"
bind:value={cores}
classes="flex-1"
/>
<CustomValueInput
label="CPU Threads"
placeholder="1"
type="number"
bind:value={threads}
classes="flex-1"
/>
<CustomValueInput
label="Memory Size"
placeholder="10G"
bind:value={humanSize}
classes="flex-1"
/>
</div>
{#if pptDevices && pptDevices.length > 0}
<p class="font-medium">PCI Passthrough</p>
<div class="border p-4">
<ScrollArea orientation="vertical" class="h-60 w-full">
{#each checkboxItems as item (item.pptId)}
<div class="mb-3 border p-4">
<div class="flex items-start space-x-3">
<Checkbox
id={item.pptId}
data-cbid={item.pptId}
checked={selectedPptIds.includes(item.pptId)}
onCheckedChange={(v: boolean | 'indeterminate') => {
if (typeof v === 'boolean') toggle(item.pptId, v);
}}
/>
<div class="grid gap-1.5 leading-none">
<Label for={item.pptId} class="text-sm font-medium">
{item.device.names.device}{item.device.names.vendor}
</Label>
<p class="text-muted-foreground text-sm">
pci{item.device.domain}:{item.device.bus}:{item.device.device}:{item.device
.function}
</p>
</div>
</div>
</div>
{/each}
</ScrollArea>
</div>
{/if}
</div>
@@ -0,0 +1,95 @@
<script lang="ts">
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
import CustomComboBox from '$lib/components/ui/custom-input/combobox.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import type { SwitchList } from '$lib/types/network/switch';
import { Label } from '$lib/components/ui/label/index.js';
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
import { onMount } from 'svelte';
interface Props {
switch: number;
mac: string;
emulation: string;
switches: SwitchList;
}
let {
switch: nwSwitch = $bindable(),
mac = $bindable(),
emulation = $bindable(),
switches
}: Props = $props();
let comboBoxes = $state({
emulation: {
open: false,
value: 'virtio',
options: [
{ label: 'VirtIO', value: 'virtio' },
{ label: 'E1000', value: 'e1000' }
]
}
});
let swStr = $state('');
onMount(() => {
swStr = nwSwitch.toString();
});
$effect(() => {
if (swStr) {
nwSwitch = parseInt(swStr) || 0;
}
});
</script>
{#snippet radioItem(id: number, name: string)}
{@const i = `radio-${id}`}
<div class="mb-2 flex items-center space-x-3 rounded-lg border p-4">
<RadioGroup.Item value={id.toString()} id={i} />
<Label for={i} class="flex flex-col gap-2">
<p>{name}</p>
<p class="text-muted-foreground text-sm">
{name === 'None'
? 'No network switch will be allocated now, you can add it later'
: 'Standard switch'}
</p>
</Label>
</div>
{/snippet}
<div class="flex flex-col gap-4 p-4">
<RadioGroup.Root bind:value={swStr} class="border p-2">
<ScrollArea orientation="vertical" class="h-60 w-full max-w-full">
{#if switches && switches.standard}
{#each switches.standard ?? [] as sw}
{@render radioItem(sw.id, sw.name)}
{/each}
{/if}
{@render radioItem(0, 'None')}
</ScrollArea>
</RadioGroup.Root>
{#if swStr !== '0'}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<CustomComboBox
bind:open={comboBoxes.emulation.open}
label="Emulation Type"
bind:value={emulation}
data={comboBoxes.emulation.options}
classes="flex-1 space-y-1"
placeholder="Select emulation type"
width="w-[40%]"
></CustomComboBox>
<CustomValueInput
label="MAC Address"
placeholder="56:49:fc:94:9b:4f"
bind:value={mac}
classes="flex-1 space-y-1"
/>
</div>
{/if}
</div>
@@ -0,0 +1,230 @@
<script lang="ts">
import CustomComboBox from '$lib/components/ui/custom-input/combobox.svelte';
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
import { Label } from '$lib/components/ui/label/index.js';
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { type Download } from '$lib/types/utilities/downloader';
import type { Dataset } from '$lib/types/zfs/dataset';
import humanFormat from 'human-format';
interface Props {
volumes: Dataset[];
filesystems: Dataset[];
downloads: Download[];
type: string;
guid: string;
size: number;
emulation: string;
iso: string;
}
let {
volumes,
filesystems,
downloads,
type = $bindable(),
guid = $bindable(),
size = $bindable(),
emulation = $bindable(),
iso = $bindable()
}: Props = $props();
function details(type: string): [string, string] {
switch (type) {
case 'zvol':
return ['ZFS Volume', 'Block devices managed by ZFS'];
case 'raw':
return ['Raw Disk', 'Disk images that can be used with any filesystem'];
case 'none':
return ['No Storage', 'No storage will be allocated for this virtual machine'];
default:
return ['', ''];
}
}
let isos = $derived.by(() => {
const options = [];
if (downloads && downloads.length > 0) {
for (const download of downloads) {
if (download.progress === 100) {
if (download.type === 'http') {
if (download.name.endsWith('.iso')) {
options.push({
label: download.name,
value: download.uuid
});
}
} else if (download.type === 'torrent') {
for (const file of download.files) {
if (file.name.endsWith('.iso')) {
options.push({
label: file.name,
value: download.uuid
});
}
}
}
}
}
}
options.push({
label: 'None',
value: 'None'
});
return options;
});
let comboBoxes = $state({
volumes: {
open: false,
options: [] as { label: string; value: string }[]
},
filesystems: {
open: false,
options: [] as { label: string; value: string }[]
},
emulationType: {
open: false,
value: 'virtio',
options: [
{ label: 'VirtIO', value: 'virtio-blk' },
{
label: 'AHCI-HD',
value: 'ahci-hd'
},
{
label: 'NVMe',
value: 'nvme'
}
]
},
isos: {
open: false,
options: isos || []
}
});
$effect(() => {
if (isos) {
comboBoxes.isos.options = isos.map((iso) => ({
label: iso.label || iso.value,
value: iso.value || ''
}));
}
});
$effect(() => {
if (volumes || filesystems) {
comboBoxes.volumes.options = volumes
.filter((v) => v.properties.volmode && v.properties.volmode === 'dev')
.map((v) => ({
label: v.name,
value: v.properties.guid || ''
}));
comboBoxes.filesystems.options = filesystems.map((fs) => ({
label: fs.name,
value: fs.properties.guid || ''
}));
}
});
let humanSize = $state('1024 M');
$effect(() => {
if (humanSize) {
try {
const parsed = humanFormat.parse.raw(humanSize);
console.log(parsed);
size = parsed.factor * parsed.value;
} catch (e) {
size = 1024;
}
}
});
</script>
{#snippet radioItem(type: string)}
<div class="mb-2 flex items-center space-x-3 rounded-lg border p-4">
<RadioGroup.Item value={type} id={type} />
<Label for={type} class="flex flex-col gap-2">
<p>{details(type)[0]}</p>
<p class="text-muted-foreground text-sm">
{details(type)[1]}
</p>
</Label>
</div>
{/snippet}
{#snippet storageDetail(type: string)}
{#if type === 'zvol'}
<CustomComboBox
bind:open={comboBoxes.volumes.open}
label="ZFS Volume"
bind:value={guid}
data={comboBoxes.volumes.options}
classes="flex-1 space-y-1"
placeholder="Select ZFS volume"
width="w-[70%]"
></CustomComboBox>
{/if}
{#if type === 'raw'}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<CustomValueInput
label="Disk Size"
placeholder="10G"
bind:value={humanSize}
classes="flex-1 space-y-1"
/>
<CustomComboBox
bind:open={comboBoxes.filesystems.open}
label="Filesystem Dataset"
bind:value={guid}
data={comboBoxes.filesystems.options}
classes="flex-1 space-y-1"
placeholder="Select filesystem"
width="w-[60%]"
></CustomComboBox>
</div>
{/if}
<CustomComboBox
bind:open={comboBoxes.emulationType.open}
label="Emulation Type"
bind:value={emulation}
data={comboBoxes.emulationType.options}
classes="flex-1 space-y-1"
placeholder="Select emulation type"
width="w-[40%]"
></CustomComboBox>
{/snippet}
<div class="flex flex-col gap-4 p-4">
<RadioGroup.Root bind:value={type} class="border p-2">
<ScrollArea orientation="vertical" class="h-60 w-full max-w-full">
{#each ['zvol', 'raw', 'none'] as storageType}
{@render radioItem(storageType)}
{/each}
</ScrollArea>
</RadioGroup.Root>
{#if type !== 'none'}
{@render storageDetail(type)}
{/if}
<CustomComboBox
bind:open={comboBoxes.isos.open}
label="Installation Media"
bind:value={iso}
data={comboBoxes.isos.options}
classes="flex-1 space-y-1"
placeholder="Select installation media"
width="w-[70%]"
></CustomComboBox>
</div>
@@ -0,0 +1,181 @@
<script>
import { logOut } from '$lib/api/auth';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sheet from '$lib/components/ui/sheet/index.js';
import { openTerminal, terminalStore } from '$lib/stores/terminal.svelte';
import Icon from '@iconify/svelte';
import { mode, toggleMode } from 'mode-watcher';
import CreateVM from './CreateVM/CreateVM.svelte';
let menuData = $state({
createVM: {
open: false
},
menuItems: [
{ icon: 'ic:baseline-settings', label: 'My Settings', shortcut: '⌘S' },
{ icon: 'solar:key-bold', label: 'Password', shortcut: '⇧⌘P' },
{ icon: 'ic:round-lock', label: 'TFA', shortcut: '⌘K' },
{ icon: 'mdi:palette', label: 'Color Theme', shortcut: '⌘⇧T' },
{ icon: 'meteor-icons:language', label: 'Language', shortcut: '⌘K' }
]
});
</script>
<header class="sticky top-0 flex h-[5vh] items-center gap-4 border-b px-2 md:h-[4vh]">
<nav
class="hidden flex-col gap-2 text-lg font-medium md:items-center md:gap-2 md:text-sm lg:flex lg:flex-row lg:gap-4"
>
<div class="flex items-center space-x-2">
{#if mode.current === 'dark'}
<img src="/logo/white.svg" alt="Sylve Logo" class="h-6 w-auto max-w-[100px]" />
{:else}
<img src="/logo/black.svg" alt="Sylve Logo" class="h-6 w-auto max-w-[100px]" />
{/if}
<p class="font-normal tracking-[.45em]">SYLVE</p>
</div>
<!-- <form class="ml-auto flex-1 sm:flex-initial">
<div class="relative">
<Icon icon="ic:sharp-search" class="absolute left-2.5 top-1.5 h-4 w-4" />
<Input
type="search"
placeholder="Search products..."
class="h-7 pl-8 sm:w-[300px] md:w-[200px] lg:w-[300px]"
/>
</div>
</form> -->
</nav>
<Sheet.Root>
<Sheet.Trigger asChild>
<Button variant="outline" size="icon" class="h-7 shrink-0 lg:hidden">
<Icon icon="material-symbols:menu-rounded" class="h-5 w-5" />
<span class="sr-only">Toggle navigation menu</span>
</Button>
</Sheet.Trigger>
<Sheet.Content side="left">
<!-- mobile view -->
<nav class="flex flex-col text-lg font-medium">
<div class="mt-4 flex items-center space-x-2">
<img src="/logo/white.svg" alt="Sylve Logo" class="h-6 w-auto max-w-[100px]" />
<p class="font-normal tracking-[.45em]">SYLVE</p>
</div>
<p class="mt-4 whitespace-nowrap">Virtual Environment 0.0.1</p>
<!-- <Button size="sm" class="mt-4 h-8 bg-neutral-600 text-white hover:bg-neutral-700">
<Icon icon="material-symbols-light:mail-outline-sharp" class="mr-2 h-4 w-4" />
Documentation
</Button> -->
<!-- <Button
size="sm"
class="h-6"
onclick={() => (menuData.createVM.open = !menuData.createVM.open)}
>
<Icon
icon="material-symbols:monitor-outline-rounded pointer-events-none"
class="mr-1.5 h-5 w-5"
/>
Create VM
</Button>
<CreateVM open={menuData.createVM.open} /> -->
<!-- <CreateDialog
title="Create: Jail"
tabs={ctTabs}
icon="ph:cube-fill"
buttonText="Create Jail"
buttonClass="h-8 mt-4"
/> -->
</nav>
</Sheet.Content>
</Sheet.Root>
<div class="flex w-full items-center justify-end gap-2 md:ml-auto">
<!-- <div class="relative lg:hidden">
<Icon
icon="ic:sharp-search"
class="text-muted-foreground absolute left-2.5 top-1.5 h-4 w-4"
/>
<Input
type="search"
placeholder="Search products..."
class="h-7 pl-8 sm:w-[300px] md:w-[200px] lg:w-[300px]"
/>
</div> -->
<!-- desktop view -->
<div class="hidden items-center gap-2 lg:inline-flex">
<Button
size="sm"
variant="ghost"
class="relative z-[9999] flex h-6 items-center justify-center px-0"
onclick={() => openTerminal()}
>
<Icon icon="garden:terminal-cli-stroke-16" class="h-5 w-5" />
{#if $terminalStore.tabs.length > 0}
<span
class="absolute -right-1 -top-1 flex h-4 min-w-[8px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white"
>
{$terminalStore.tabs.length}
</span>
{/if}
</Button>
<Button
size="sm"
class="h-6"
onclick={() => (menuData.createVM.open = !menuData.createVM.open)}
>
<Icon icon="material-symbols:monitor-outline-rounded" class="mr-1.5 h-5 w-5" />
Create VM
</Button>
{#if menuData.createVM.open}
<CreateVM bind:open={menuData.createVM.open} />
{/if}
<!--
<CreateDialog
title="Create: Jail"
tabs={ctTabs}
icon="tabler:prison"
buttonText="Create Jail"
buttonClass="h-6"
/> -->
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button
variant="outline"
class="border-border flex h-7 items-center gap-1 rounded-md border"
><Icon icon="mdi:user" class="h-4 w-4" /> Root <Icon
icon="famicons:chevron-down"
class="h-4 w-4"
/>
<span class="sr-only">Toggle user menu</span></Button
>
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-56">
<DropdownMenu.Group>
{#each menuData.menuItems as { icon, label, shortcut }}
<DropdownMenu.Item
class="cursor-pointer"
onclick={() => label === 'Color Theme' && toggleMode()}
>
<Icon {icon} class="mr-2 h-4 w-4" />
<span>{label}</span>
{#if shortcut}
<DropdownMenu.Shortcut>{shortcut}</DropdownMenu.Shortcut>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item class="cursor-pointer" onclick={() => logOut()}>
<Icon icon="ic:twotone-logout" class="mr-2 h-4 w-4" />
<span>Log out</span>
<DropdownMenu.Shortcut>⌘⇧Q</DropdownMenu.Shortcut>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</header>
@@ -0,0 +1,129 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import Icon from '@iconify/svelte';
interface Props {
open: boolean;
titles: {
icon?: string;
main: string;
key: string;
value: string;
};
type: string;
KV:
| Record<string, string | number | Record<string, string | number>>
| Array<Record<string, string | number>>;
actions: {
close: () => void;
};
}
let { open, titles, type, KV, actions }: Props = $props();
let tableHeaders = $derived.by(() => {
if (Array.isArray(KV)) {
return Object.keys(KV[0]);
} else {
return [];
}
});
let expandedObjects: Record<string, boolean> = $state({});
function toggleObjectExpansion(key: string) {
expandedObjects[key] = !expandedObjects[key];
}
</script>
<Dialog.Root bind:open closeOnOutsideClick={false}>
<Dialog.Content class="flex max-h-[80vh] w-[90%] flex-col gap-0 overflow-hidden p-5 lg:max-w-4xl">
<div class="flex items-center justify-between">
<div class="flex items-center">
{#if titles.icon}
<Icon icon={titles.icon} class="h-6 w-6" />
{/if}
<h2 class="ml-2 text-lg font-semibold">{titles.main}</h2>
</div>
<Dialog.Close
class="flex h-6 w-6 items-center justify-center rounded-sm opacity-70 transition-opacity hover:opacity-100"
onclick={actions.close}
>
<Icon icon="material-symbols:close-rounded" class="h-6 w-6" />
</Dialog.Close>
</div>
<div class="mt-2 max-h-[60vh] overflow-y-auto">
<Table.Root class="w-full table-auto border-collapse">
<Table.Header class="bg-background sticky top-0 z-[50]">
<Table.Row>
{#if tableHeaders.length > 0}
{#each tableHeaders as header}
<Table.Head class="h-10 px-3 py-2">{header}</Table.Head>
{/each}
{:else}
<Table.Head class="h-10 px-3 py-2">{titles.key}</Table.Head>
<Table.Head class="h-10 px-3 py-2">{titles.value}</Table.Head>
{/if}
</Table.Row>
</Table.Header>
<Table.Body>
{#if tableHeaders.length > 0}
{#each KV as Array<Record<string, string | number>> as row}
<Table.Row>
{#each tableHeaders as header}
<Table.Cell class="h-10 px-3 py-2">{row[header]}</Table.Cell>
{/each}
</Table.Row>
{/each}
{:else}
{#each Object.entries(KV) as [key, value]}
{#if typeof value === 'object' && value !== null && !Array.isArray(value)}
<Table.Row>
<Table.Cell class="h-10 w-1/2 whitespace-nowrap px-1 py-2 font-medium">
<button
class="flex w-full items-center gap-1 text-left"
onclick={() => toggleObjectExpansion(key)}
>
<Icon
icon={expandedObjects[key]
? 'material-symbols:keyboard-arrow-down'
: 'material-symbols:keyboard-arrow-right'}
class="h-4 w-4 opacity-70"
/>
{key}
</button>
</Table.Cell>
<Table.Cell class="h-10 px-3 py-2 italic opacity-50">
Object ({Object.keys(value).length} properties)
</Table.Cell>
</Table.Row>
{#if expandedObjects[key]}
{#each Object.entries(value) as [nestedKey, nestedValue]}
<Table.Row>
<Table.Cell class="h-10 py-2 pl-8 pr-3 opacity-90">
{nestedKey}
</Table.Cell>
<Table.Cell class="h-10 px-3 py-2">
{nestedValue}
</Table.Cell>
</Table.Row>
{/each}
{/if}
{:else}
<Table.Row>
<Table.Cell class="h-10 px-3 py-2">{key}</Table.Cell>
<Table.Cell class="h-10 px-3 py-2">{value}</Table.Cell>
</Table.Row>
{/if}
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,17 @@
<script lang="ts">
import * as Table from '$lib/components/ui/table/index.js';
let { data }: { data: { key: string; value: string }[] } = $props();
</script>
<Table.Root>
<Table.Header></Table.Header>
<Table.Body>
{#each data as item, i (i)}
<Table.Row>
<Table.Cell class="uppercase">{item.key}</Table.Cell>
<Table.Cell>{item.value}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
@@ -0,0 +1,91 @@
<script lang="ts">
import type { HistoricalData } from '$lib/types/common';
import { formatValue, getDateFormatByInterval } from '$lib/utils/zfs/pool';
import { curveCatmullRom } from 'd3-shape';
import { format as dateFormat } from 'date-fns';
import { toZonedTime } from 'date-fns-tz';
import { AreaChart, Axis, Tooltip } from 'layerchart';
interface Props {
data: HistoricalData[][];
keys: {
key: string;
title: string;
color: string;
}[];
valueType: string;
unformattedKeys?: string[];
interval?: string;
}
const { data = $bindable([]), keys, valueType, unformattedKeys, interval }: Props = $props();
let flat: HistoricalData[] = $derived.by(() => data.flat());
let series = $derived.by(() => {
let uniqueKeys = Array.from(
new Set(flat.flatMap((d) => Object.keys(d).filter((k) => k !== 'date')))
);
return uniqueKeys.map((key, i) => ({
key,
label: keys.find((k) => k.key === key)?.title || key,
color: keys.find((k) => k.key === key)?.color || 'pink'
}));
});
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const formatWithTimeZone = (date: Date | string, formatStr: string) => {
const zonedDate = toZonedTime(new Date(date), timeZone);
return dateFormat(zonedDate, formatStr);
};
let dateFormatString = $derived.by(() => getDateFormatByInterval(Number(interval), false));
</script>
<AreaChart
props={{ area: { curve: curveCatmullRom } }}
data={flat}
x="date"
{series}
grid={true}
legend
tooltip={{ mode: 'quadtree' }}
renderContext="svg"
>
<svelte:fragment slot="axis">
<Axis
placement="bottom"
grid={{ class: 'stroke-none' }}
tickLength={15}
tickLabelProps={{
rotate: -30,
class: 'fill-black dark:fill-white stroke-none'
}}
format={(d) => formatWithTimeZone(d, interval ? dateFormatString : 'hh:mm a')}
/>
<Axis
placement="left"
grid={{ class: 'stroke-none' }}
tickLength={5}
tickLabelProps={{ class: 'fill-black dark:fill-white stroke-none' }}
format={(d) => String(formatValue(d, unformattedKeys, valueType))}
/>
</svelte:fragment>
<svelte:fragment slot="tooltip">
<Tooltip.Root class="bg-card border-border border p-2" let:data>
<Tooltip.Header class="border-border border-b pb-1 text-sm">
{formatWithTimeZone(data.date, interval ? dateFormatString : 'hh:mm a')}
</Tooltip.Header>
<Tooltip.List>
{#each Object.entries(data).filter(([key]) => key !== 'date') as [key, value]}
<Tooltip.Item
label={series.find((s) => s.key === key)?.label || key}
value={formatValue(Number(value), unformattedKeys, valueType)}
color={series.find((s) => s.key === key)?.color || 'pink'}
/>
{/each}
</Tooltip.List>
</Tooltip.Root>
</svelte:fragment>
</AreaChart>
@@ -0,0 +1,25 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import Icon from '@iconify/svelte';
interface Props {
open: boolean;
title: string;
description: string;
iconColor?: string;
}
let { open = $bindable(), iconColor = 'text-red-500', title, description }: Props = $props();
</script>
<Dialog.Root bind:open closeOnOutsideClick={false}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header class="flex flex-col items-center justify-center text-center">
<Dialog.Title class="mb-2 text-lg font-semibold">{title}</Dialog.Title>
<Icon icon="mdi:loading" class={`mb-4 animate-spin text-4xl ${iconColor}`} />
<Dialog.Description class="text-muted-foreground mt-3 text-sm">
{@html description}
</Dialog.Description>
</Dialog.Header>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,152 @@
<script lang="ts">
import { page } from '$app/stores';
import { revokeJWT } from '$lib/api/auth';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import { hostname, language as langStore } from '$lib/stores/basic';
import { getTranslation } from '$lib/utils/i18n';
import Icon from '@iconify/svelte';
import { mode } from 'mode-watcher';
import { onDestroy, onMount } from 'svelte';
import { _ } from 'svelte-i18n';
interface Props {
onLogin: (
username: string,
password: string,
type: string,
language: string,
remember: boolean
) => void;
}
let { onLogin }: Props = $props();
let username = $state('');
let password = $state('');
let authType = $state('sylve');
let language = $state('en');
let remember = $state(false);
let loading = $state(false);
$effect(() => {
if ($page.url.search.includes('loggedOut')) {
revokeJWT();
}
});
async function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
if (loading) return;
loading = true;
try {
await onLogin(username, password, authType, language, remember);
} catch (error) {
console.error('Login error:', error);
} finally {
loading = false;
}
}
}
onMount(() => {
window.addEventListener('keydown', handleKeydown);
});
onDestroy(() => {
window.removeEventListener('keydown', handleKeydown);
});
const authTypeValue = $derived(getTranslation(`auth.${authType}`, authType));
const languageValue = $derived(getTranslation(`common.languages.${language}`, language));
</script>
<div class="fixed inset-0 flex items-center justify-center px-3">
<Card.Root class="w-full max-w-lg rounded-lg shadow-lg">
<Card.Header class="flex flex-row items-center justify-center gap-2">
{#if mode.current === 'dark'}
<img src="/logo/white.svg" alt="Sylve Logo" class="mt-2 h-8 w-auto" />
{:else}
<img src="/logo/black.svg" alt="Sylve Logo" class="h-8 w-auto" />
{/if}
<p class="ml-2 text-xl font-medium tracking-[.45em] text-gray-800 dark:text-white">SYLVE</p>
</Card.Header>
<Card.Content class="space-y-4 p-6">
<div class="flex items-center gap-2">
<Label for="username" class="w-44">{$_('auth.username')}</Label>
<Input
id="username"
class="h-8 w-full"
type="text"
placeholder={$_('common.example')}
bind:value={username}
required
/>
</div>
<div class="flex items-center gap-2">
<Label for="password" class="w-44">{$_('auth.password')}</Label>
<Input
id="password"
type="password"
placeholder="●●●●●●●●"
class="h-8 w-full"
bind:value={password}
required
/>
</div>
<div class="flex items-center gap-2">
<Label for="realm" class="w-44">{$_('auth.realm')}</Label>
<Select.Root type="single" bind:value={authType}>
<Select.Trigger class="h-8 w-full">
{authTypeValue}
</Select.Trigger>
<Select.Content>
<Select.Item value="pam">{$_('auth.pam')}</Select.Item>
<Select.Item value="sylve">{$_('auth.sylve')}</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="flex items-center gap-2">
<Label for="language" class="w-44">{$_('auth.language')}</Label>
<Select.Root type="single" bind:value={language}>
<Select.Trigger class="h-8 w-full">
{languageValue}
</Select.Trigger>
<Select.Content>
<Select.Item value="en">English</Select.Item>
<Select.Item value="mal">Malayalam</Select.Item>
</Select.Content>
</Select.Root>
</div>
</Card.Content>
<Card.Footer class="flex items-center justify-between px-6 py-4">
<div class="flex items-center space-x-2">
<Checkbox id="remember" bind:checked={remember} />
<Label for="remember" class="text-sm font-medium">Remember Me</Label>
</div>
<Button
onclick={() => {
onLogin(username, password, authType, language, remember);
}}
size="sm"
class="w-20 rounded-md bg-blue-700 text-white hover:bg-blue-600"
>
{#if loading}
<Icon icon="line-md:loading-loop" width="24" height="24" />
{:else}
Login
{/if}
</Button>
</Card.Footer>
</Card.Root>
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import type { PieChartData } from '$lib/types/common';
import humanFormat from 'human-format';
import { PieChart, Tooltip } from 'layerchart';
interface Data {
containerClass: string;
data: PieChartData[];
formatter?: 'size-formatter' | 'default';
}
const { containerClass, data, formatter = 'default' }: Data = $props();
</script>
<div class={containerClass}>
<PieChart
{data}
key="label"
value="value"
cRange={data.map((d) => d.color)}
renderContext="svg"
legend
><svelte:fragment slot="tooltip">
<Tooltip.Root let:data class="bg-secondary">
<Tooltip.List>
<Tooltip.Item
label={data.label}
value={formatter === 'size-formatter' ? humanFormat(data.value) : data.value}
/>
</Tooltip.List>
</Tooltip.Root>
</svelte:fragment>
</PieChart>
</div>
@@ -0,0 +1,274 @@
<script lang="ts">
import { store } from '$lib/stores/auth';
import { getDefaultTitle, terminalStore } from '$lib/stores/terminal.svelte';
import {
Xterm,
XtermAddon,
type FitAddon,
type ITerminalInitOnlyOptions,
type ITerminalOptions,
type Terminal
} from '@battlefieldduck/xterm-svelte';
import Icon from '@iconify/svelte';
import adze from 'adze';
import { nanoid } from 'nanoid';
import { onMount, untrack } from 'svelte';
import { fade, scale } from 'svelte/transition';
let terminal = $state<Terminal>();
let ws = $state<WebSocket>();
let fitAddonGlobal = $state<FitAddon>();
let options: ITerminalOptions & ITerminalInitOnlyOptions = {
cursorBlink: true
};
let tabsCount = $derived.by(() => {
return $terminalStore.tabs.length;
});
let currentTab = $derived.by(() => {
return $terminalStore.tabs.find((tab) => tab.id === $terminalStore.activeTabId);
});
async function killSession(sessionId: string): Promise<boolean> {
return new Promise((resolve) => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
resolve(false);
return;
}
const onMessage = (event: MessageEvent) => {
if (event.data) {
if (typeof event.data === 'string') {
if (event.data.includes(`Session killed: ${sessionId}`)) {
ws?.removeEventListener('message', onMessage);
resolve(true);
}
}
}
};
ws.addEventListener('message', onMessage);
ws.send(new TextEncoder().encode('\x02' + JSON.stringify({ kill: sessionId })));
setTimeout(() => {
ws?.removeEventListener('message', onMessage);
resolve(false);
}, 2000);
});
}
async function onLoad() {
try {
if (!currentTab) return;
ws?.close();
terminal?.clear();
terminal?.reset();
const fitAddon = new (await XtermAddon.FitAddon()).FitAddon();
terminal?.loadAddon(fitAddon);
fitAddon.fit();
ws = new WebSocket(`/api/info/terminal?id=${currentTab?.id}`, ['Bearer', $store]);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
if (!currentTab) return;
adze.info(`Terminal WebSocket connected for tab ${currentTab?.id}`);
if (terminal) {
const dimensions = fitAddon.proposeDimensions();
(ws as WebSocket).send(
new TextEncoder().encode(
'\x01' + JSON.stringify({ rows: dimensions?.rows, cols: dimensions?.cols })
)
);
fitAddonGlobal = fitAddon;
}
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
if (terminal) {
terminal.write(new Uint8Array(event.data));
}
}
};
ws.onclose = () => {
if (!currentTab) return;
adze.info(`Terminal WebSocket disconnected for tab ${currentTab?.id}`);
};
} catch (e) {
adze.error('Failed to connect to terminal WebSocket', { error: e });
}
}
function onData(data: string) {
ws?.send(new TextEncoder().encode('\x00' + data));
}
async function visiblityAction(t: string, e?: MouseEvent | string) {
if (t === 'window-minimize') {
$terminalStore.isMinimized = true;
return;
}
if (t === 'window-close') {
const tabsToKill = [...$terminalStore.tabs];
for (const tab of tabsToKill) {
await killSession(tab.id);
}
$terminalStore.tabs = [];
$terminalStore.isOpen = false;
ws?.close();
}
if (t === 'tab-close') {
const event = e as MouseEvent;
if (event) {
const target = event.target as HTMLElement;
const parent = target.closest('button');
if (parent) {
const tabId = parent.getAttribute('data-id');
if (tabId) {
await killSession(tabId);
$terminalStore.tabs = $terminalStore.tabs.filter((tab) => tab.id !== tabId);
if ($terminalStore.tabs.length > 0) {
$terminalStore.activeTabId = $terminalStore.tabs[0].id;
}
}
}
}
}
if (t === 'tab-select') {
const tabId = e as string;
$terminalStore.activeTabId = tabId;
}
}
function addTab() {
const newTab = {
id: nanoid(5),
title: getDefaultTitle()
};
$terminalStore.tabs = [...$terminalStore.tabs, newTab];
$terminalStore.activeTabId = newTab.id;
}
let innerWidth = $state(0);
$effect(() => {
if (innerWidth) {
untrack(() => {
fitAddonGlobal?.fit();
const dimensions = fitAddonGlobal?.proposeDimensions();
ws?.send(
new TextEncoder().encode(
'\x01' + JSON.stringify({ rows: dimensions?.rows, cols: dimensions?.cols })
)
);
});
}
});
</script>
<svelte:window bind:innerWidth />
{#if $terminalStore.isOpen && !$terminalStore.isMinimized}
<div
class="fixed inset-0 z-[9998] bg-black/30 backdrop-blur-sm transition-all duration-150"
></div>
<div
class="fixed inset-0 z-[9999] flex items-center justify-center transition-all duration-150"
in:scale={{ start: 0.9, duration: 150 }}
out:scale={{ start: 0.9, duration: 150 }}
>
<div
class="border-muted bg-muted-foreground/10 relative flex w-[60%] flex-col rounded-lg border-4"
>
<div class="bg-primary-foreground flex items-center justify-between p-2">
<!-- Add Tab Button -->
<div class="flex items-center gap-2">
<span>{$terminalStore.title}</span>
</div>
<!-- Minimize / Close -->
<div class="flex space-x-3">
<button
class="rounded-full transition-colors duration-300 ease-in-out hover:bg-yellow-600 hover:text-white"
onclick={() => visiblityAction('window-minimize')}
title="Minimize"
>
<Icon icon="mdi:window-minimize" class="h-5 w-5" />
</button>
<button
class="rounded-full transition-colors duration-300 ease-in-out hover:bg-red-500 hover:text-white"
onclick={() => visiblityAction('window-close')}
title="Close"
>
<Icon icon="mdi:close" class="h-5 w-5" />
</button>
</div>
</div>
<!-- Available Tabs -->
<div class="dark:bg-muted/30 flex overflow-x-auto bg-white">
{#each $terminalStore.tabs as tab}
<div
class="border-muted-foreground/40 flex cursor-pointer items-center px-3.5 py-2 {tab.id ===
$terminalStore.activeTabId
? 'bg-muted-foreground/40 dark:bg-muted-foreground/25 '
: 'border-muted-foreground/25 hover:bg-muted-foreground/25 border-x border-t'}"
onclick={() => visiblityAction('tab-select', tab.id)}
onkeydown={(e) =>
(e.key === 'Enter' || e.key === ' ') && visiblityAction('tab-select', tab.id)}
role="button"
tabindex="0"
>
<span class="mr-2 whitespace-nowrap text-sm">{tab.title}</span>
{#if tabsCount > 1}
<button
class="rounded-full transition-colors duration-300 ease-in-out hover:bg-red-500 hover:text-white"
data-id={tab.id}
onclick={(e) => {
e.stopPropagation();
visiblityAction('tab-close', e);
}}
>
<Icon icon="mdi:close" class="h-4 w-4" />
</button>
{/if}
</div>
{/each}
<div
class="hover:border-muted-foreground/30 hover:bg-muted-foreground/30 flex items-center justify-center border px-1"
>
<button
class="dark:hover-bg-muted flex h-6 w-6 items-center justify-center rounded"
onclick={() => addTab()}
title="Add new tab"
>
<Icon icon="ic:sharp-plus" class="h-5 w-5" />
</button>
</div>
</div>
<!-- Terminal Body -->
<div
id="terminal-container"
class="relative min-h-[456px] w-full flex-grow overflow-hidden bg-black"
>
{#each $terminalStore.tabs as tab}
{#if tab.id === $terminalStore.activeTabId}
<div in:fade={{ duration: 150 }}>
<Xterm bind:terminal {options} {onLoad} {onData} />
</div>
{/if}
{/each}
</div>
</div>
</div>
{/if}
@@ -0,0 +1,195 @@
<script>
import { onMount } from 'svelte';
let darkMode = $state(false);
onMount(() => {
darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
});
</script>
<div class="flex h-screen flex-col items-center justify-center gap-3 overflow-hidden">
<svg
version="1.1"
id="thrb"
width="120"
height="136"
viewBox="0 0 624.98667 707.78264"
xmlns="http://www.w3.org/2000/svg"
>
<defs id="defs1">
<clipPath clipPathUnits="userSpaceOnUse" id="clipPath2">
<path
d="M 0,2000 H 2000 V 0 H 0 Z"
transform="translate(-901.62842,-925.7178)"
id="path2"
/>
</clipPath>
<clipPath clipPathUnits="userSpaceOnUse" id="clipPath4">
<path
d="M 0,2000 H 2000 V 0 H 0 Z"
transform="translate(-966.65875,-884.12602)"
id="path4"
/>
</clipPath>
<clipPath clipPathUnits="userSpaceOnUse" id="clipPath6">
<path
d="M 0,2000 H 2000 V 0 H 0 Z"
transform="translate(-847.32865,-944.4102)"
id="path6"
/>
</clipPath>
<clipPath clipPathUnits="userSpaceOnUse" id="clipPath8">
<path
d="M 0,2000 H 2000 V 0 H 0 Z"
transform="translate(-637.24852,-944.4102)"
id="path8"
/>
</clipPath>
<clipPath clipPathUnits="userSpaceOnUse" id="clipPath10">
<path
d="M 0,2000 H 2000 V 0 H 0 Z"
transform="translate(-742.28862,-1129.9302)"
id="path10"
/>
</clipPath>
</defs>
<g id="layer-MC0" transform="translate(-1048.7193,-975.76448)">
<path
id="thrbP1"
d="m 0,0 c 0,-7.946 -4.231,-15.291 -11.105,-19.276 l -104.371,-60.512 c -7.887,-4.573 -17.764,1.117 -17.764,10.234 V 58.619 c 0,7.944 4.229,15.287 11.1,19.273 l 104.376,60.52 C -9.877,142.985 0,137.294 0,128.178 Z m -145.311,108.103 c -8.948,-5.189 -19.99,-5.189 -28.939,0 l -103.426,59.971 c -6.451,3.74 -6.451,13.056 10e-4,16.797 l 103.988,60.293 c 8.947,5.188 19.987,5.188 28.935,0 L -41.324,185.2 c 6.451,-3.74 6.451,-13.055 0,-16.797 z M -185.44,-66.169 c 0,-10.557 -11.438,-17.146 -20.57,-11.851 l -100.67,58.374 c -7.428,4.307 -12,12.244 -12,20.83 v 123.232 c 0,10.556 11.437,17.145 20.57,11.85 l 100.68,-58.374 c 7.422,-4.308 11.99,-12.242 11.99,-20.824 z m 210.08,256.781 -13.84,8.02 -10.8,6.26 -57.79,33.51 -63.92,36.9 -26.38,15.23 c -6.96,4.02 -15.54,4.02 -22.5,0 l -26.26,-15.159 -6.43,-3.711 -115.4,-66.91 -11.13,-6.45 -14.22,-8.25 -0.03,-0.02 c -5.14,-4.24 -8.19,-10.579 -8.19,-17.36 v -196.77 c 0,-7.12 3.36,-13.759 8.97,-17.98 l 24.6,-14.26 52.9,-30.68 69.07,-39.87 11.27,-6.509 14.85,-8.571 c 6.96,-4.019 15.54,-4.019 22.5,0 l 14.85,8.571 11.39,6.58 121.85,70.339 5.42,3.14 17.97,10.41 0.04,0.031 c 6.3,4.139 10.14,11.2 10.14,18.799 v 196.77 c 0,7.09 -3.35,13.72 -8.93,17.94"
style={darkMode
? 'fill:#4c4847;fill-opacity:1;fill-rule:nonzero;stroke:none'
: 'fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none'}
transform="matrix(1.3333333,0,0,-1.3333333,1573.6655,1428.6981)"
clip-path="url(#clipPath2)"
/>
<path
id="thrbP3"
d="m 0,0 c 0,-8.456 -4.512,-16.271 -11.835,-20.5 l -200.7,-115.873 c -3.664,-2.115 -7.746,-3.171 -11.835,-3.172 -4.089,10e-4 -8.172,1.057 -11.836,3.172 L -436.905,-20.5 c -7.324,4.229 -11.836,12.044 -11.835,20.5 v 231.748 c -10e-4,8.456 4.511,16.272 11.835,20.5 l 200.699,115.873 c 3.664,2.115 7.747,3.171 11.836,3.171 4.089,0 8.171,-1.056 11.835,-3.171 l 200.7,-115.873 C -4.512,248.02 0,240.204 0,231.748 Z m -6.835,260.908 -200.7,115.873 c -5.207,3.007 -11.024,4.512 -16.835,4.511 -5.811,0.001 -11.629,-1.504 -16.836,-4.511 L -441.905,260.908 c -10.417,-6.015 -16.835,-17.131 -16.835,-29.16 L -458.74,0 c 0,-12.029 6.418,-23.145 16.835,-29.16 l 200.699,-115.873 c 5.207,-3.007 11.025,-4.512 16.836,-4.512 5.811,0 11.629,1.505 16.835,4.512 L -6.835,-29.16 C 3.582,-23.145 10,-12.029 10,0 v 231.748 c 0,12.029 -6.417,23.145 -16.835,29.16"
style="fill:#aaaaaa;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,1660.3726,1484.1538)"
clip-path="url(#clipPath4)"
/>
<path
id="thrbP5"
d="M 0,0 C 0,-3.13 -1.67,-6.021 -4.38,-7.58 L -63.94,-41.97 V -53.521 L 0.62,-16.24 C 6.42,-12.89 10,-6.7 10,0 V 86.99 L 0,81.19 Z"
style="fill:#aaaaaa;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,1501.2658,1403.7749)"
clip-path="url(#clipPath6)"
/>
<path
id="thrbP7"
d="m 0,0 v 80.68 l -10,5.79 V 0 c 0,-6.7 3.58,-12.89 9.38,-16.24 L 63.94,-53.521 V -41.97 L 4.38,-7.58 C 1.67,-6.021 0,-3.13 0,0"
style="fill:#aaaaaa;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,1221.1589,1403.7749)"
clip-path="url(#clipPath8)"
/>
<path
id="thrbP9"
d="m 0,0 c -3.24,0 -6.48,-0.84 -9.38,-2.51 l -68.67,-39.65 9.98,-5.78 63.69,36.77 c 1.36,0.78 2.87,1.17 4.38,1.17 1.51,0 3.02,-0.39 4.38,-1.17 L 67.63,-47.69 77.61,-41.9 9.38,-2.51 C 6.48,-0.84 3.24,0 0,0"
style="fill:#aaaaaa;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,1361.2124,1156.4149)"
clip-path="url(#clipPath10)"
/>
</g>
</svg>
<p
class="animate-blurFadePerfect ml-3 flex w-full justify-center text-center font-[500] tracking-[.45em] opacity-0"
>
SYLVE
</p>
</div>
<style>
@keyframes blurFadePerfect {
0% {
opacity: 0;
filter: blur(12px);
transform: scale(1.05);
}
60% {
opacity: 1;
filter: blur(4px);
transform: scale(1);
}
100% {
opacity: 1;
filter: blur(0);
transform: scale(1);
}
}
.animate-blurFadePerfect {
animation: blurFadePerfect 1s ease-out forwards;
}
#thrb {
transform-origin: center;
animation: thrbApp 1s ease-out forwards;
}
#thrbP1,
#thrbP3,
#thrbP5,
#thrbP7,
#thrbP9 {
opacity: 0;
animation: thrbFadeIn 0.5s ease-out forwards;
}
#thrbP1 {
animation-delay: 0.2s;
}
#thrbP3 {
animation-delay: 0.4s;
}
#thrbP5 {
animation:
thrbFadeIn 0.5s ease-out forwards 0.6s,
thrbPulseSeq 3s ease-in-out infinite 2s;
}
#thrbP7 {
animation:
thrbFadeIn 0.5s ease-out forwards 0.8s,
thrbPulseSeq 3s ease-in-out infinite 3s;
}
#thrbP9 {
animation:
thrbFadeIn 0.5s ease-out forwards 1s,
thrbPulseSeq 3s ease-in-out infinite 4s;
}
@keyframes thrbApp {
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes thrbFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes thrbPulseSeq {
0%,
15%,
100% {
opacity: 1;
}
5%,
10% {
opacity: 0.3;
}
}
</style>
@@ -0,0 +1,158 @@
<script lang="ts">
import type { Column, Row } from '$lib/types/components/tree-table';
import { hasRowsChanged, matchAny } from '$lib/utils/table';
import { findRow, getAllRows, pruneEmptyChildren } from '$lib/utils/tree-table';
import { onMount, untrack } from 'svelte';
import {
TabulatorFull as Tabulator,
type ColumnDefinition,
type RowComponent
} from 'tabulator-tables';
let tableComponent: HTMLDivElement | null = null;
let table: Tabulator | null = $state(null);
interface Props {
data: {
rows: Row[];
columns: Column[];
};
name: string;
parentActiveRow?: Row[] | null;
query?: string;
multipleSelect?: boolean;
}
let {
data,
name,
parentActiveRow = $bindable([]),
query = $bindable(),
multipleSelect = true
}: Props = $props();
let tableInitialized = $state(false);
let scroll = $state([0, 0]);
let aboutToClick = $state(false);
function updateParentActiveRows() {
if (tableInitialized) {
parentActiveRow = table?.getSelectedRows().map((r) => r.getData() as Row) || [];
}
}
$effect(() => {
if (data.rows) {
untrack(async () => {
if (query && query !== '') return;
if (data.rows.length === 0) {
table?.clearData();
return;
}
const selectedIds = table?.getSelectedRows().map((row) => row.getData().id) || [];
const treeExpands = getAllRows(table?.getRows() || []).map((row) => ({
id: row.getData().id,
expanded: row.isTreeExpanded()
}));
if (hasRowsChanged(table, data.rows) && !aboutToClick) {
await table?.replaceData(pruneEmptyChildren(data.rows));
}
selectedIds.forEach((id) => {
const row = findRow(table?.getRows() || [], id);
if (row) row.select();
});
treeExpands.forEach((treeExpand) => {
const row = findRow(table?.getRows() || [], treeExpand.id);
if (row) {
treeExpand.expanded ? row.treeExpand() : row.treeCollapse();
}
});
updateParentActiveRows();
});
}
});
onMount(() => {
if (tableComponent) {
table = new Tabulator(tableComponent, {
data: pruneEmptyChildren(data.rows),
reactiveData: true,
columns: data.columns as ColumnDefinition[],
layout: 'fitColumns',
selectableRows: multipleSelect ? true : 1,
dataTreeChildIndent: 16,
dataTree: true,
dataTreeChildField: 'children',
dataTreeStartExpanded: true,
persistenceID: name,
paginationMode: 'local',
persistence: {
sort: true,
page: true,
filter: true
},
placeholder: 'No data available',
pagination: true,
paginationSize: 25,
paginationCounter: 'pages'
});
}
table?.on('rowSelected', updateParentActiveRows);
table?.on('rowDeselected', updateParentActiveRows);
table?.on('rowDblClick', (_event: UIEvent, row: RowComponent) => {
row.toggleSelect();
});
table?.on('tableBuilt', () => {
tableInitialized = true;
document.querySelector('.tabulator-footer')?.addEventListener('mouseover', () => {
aboutToClick = true;
});
document.querySelector('.tabulator-footer')?.addEventListener('mouseout', () => {
aboutToClick = false;
});
});
table?.on('scrollVertical', (top) => {
scroll = [top, scroll[1]];
});
table?.on('scrollHorizontal', (left) => {
scroll = [scroll[0], left];
});
table?.on('renderComplete', () => {
const container = document.querySelector('.tabulator-tableholder') as HTMLDivElement;
if (container) {
container.scrollTop = scroll[0];
container.scrollLeft = scroll[1];
}
});
});
function tableFilter(query: string) {
if (table && tableInitialized) {
if (query === '') {
table.clearFilter(true);
return;
}
table.setFilter(matchAny, { query });
}
}
$effect(() => {
tableFilter(query || '');
});
</script>
<div bind:this={tableComponent} class="flex-1 cursor-pointer" id={name}></div>
@@ -0,0 +1,64 @@
<script lang="ts">
import { getTranslation } from '$lib/utils/i18n';
import { capitalizeFirstLetter } from '$lib/utils/string';
import Icon from '@iconify/svelte';
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { fade, slide } from 'svelte/transition';
let expanded = $state(false);
interface Props {
query: string;
}
let { query = $bindable() }: Props = $props();
function toggleSearch() {
expanded = !expanded;
if (expanded) {
requestAnimationFrame(() => {
const input = document.getElementById('search-input');
input?.focus();
});
}
}
onMount(() => {
if (query !== '') {
expanded = true;
}
});
</script>
<div class="relative">
<div
class="bg-primary text-primary-foreground flex h-6 items-center overflow-hidden rounded-lg transition-[width] duration-300 ease-in-out"
style="width: {expanded ? '16rem' : '1.5rem'}"
>
<button
class="flex h-6 w-6 min-w-[1.5rem] shrink-0 items-center justify-center"
onclick={toggleSearch}
>
<Icon icon="mdi:magnify" class="h-5 w-5" />
</button>
{#if expanded}
<input
id="search-input"
bind:value={query}
type="text"
placeholder={`${capitalizeFirstLetter(getTranslation('common.search', 'Search'))}...`}
class="bg-primary ml-1 w-full text-sm leading-4 focus:outline-none"
in:slide={{ duration: 250, easing: cubicOut, axis: 'x' }}
out:fade={{ duration: 150 }}
onkeydown={(e) => {
if (e.key === 'Escape') {
query = '';
expanded = false;
}
}}
/>
{/if}
</div>
</div>
@@ -0,0 +1,100 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { getTranslation } from '$lib/utils/i18n';
import Icon from '@iconify/svelte';
import { _ } from 'svelte-i18n';
import { slide } from 'svelte/transition';
import SidebarElement from './TreeView.svelte';
interface SidebarProps {
label: string;
icon: string;
href?: string;
children?: SidebarProps[];
}
interface Props {
item: SidebarProps;
onToggle: (label: string) => void;
}
let { item, onToggle }: Props = $props();
let isOpen = $state(false);
const toggle = (e: MouseEvent) => {
e.preventDefault();
if (item.children) {
isOpen = !isOpen;
onToggle(item.label);
}
if (item.href) {
goto(item.href, { replaceState: false, noScroll: false });
}
};
const sidebarActive = 'rounded-md bg-primary/10 dark:bg-muted font-inter font-medium';
function isItemActive(menuItem: SidebarProps, currentUrl: string): boolean {
if (menuItem.href && currentUrl.startsWith(menuItem.href)) {
return true;
}
if (menuItem.children) {
return menuItem.children.some((child) => isItemActive(child, currentUrl));
}
return false;
}
let activeUrl = $derived($page.url.pathname);
let isActive = $derived(isItemActive(item, activeUrl));
let lastActiveUrl = $derived.by(() => {
const segments = activeUrl.split('/');
return segments[segments.length - 1];
});
function isItemOpen(menuItem: SidebarProps, currentUrl: string): boolean {
if (menuItem.href && currentUrl.startsWith(menuItem.href)) {
return true;
}
if (menuItem.children) {
return menuItem.children.some((child) => isItemOpen(child, currentUrl));
}
return false;
}
$effect(() => {
isOpen = isItemOpen(item, activeUrl);
});
</script>
<li class={`w-full`}>
<a
class={`my-0.5 flex w-full items-center justify-between px-1.5 py-0.5 ${isActive ? sidebarActive : 'hover:bg-primary/10 dark:hover:bg-muted rounded-md'}${lastActiveUrl === item.label ? '!text-primary' : ' '}`}
href={item.href}
onclick={toggle}
>
<div class="flex items-center space-x-1 text-sm">
<Icon icon={item.icon} width="18" />
<p class="font-inter cursor-pointer whitespace-nowrap">
{getTranslation(`node.${item.label}`, item.label)}
</p>
</div>
{#if item.children}
<Icon
icon={isOpen ? 'teenyicons:down-solid' : 'teenyicons:right-solid'}
class="h-3.5 w-3.5"
/>
{/if}
</a>
</li>
{#if isOpen && item.children}
<ul class="pl-5" transition:slide={{ duration: 200, easing: (t) => t }} style="overflow: hidden;">
{#each item.children as child}
<SidebarElement item={child} {onToggle} />
{/each}
</ul>
{/if}
@@ -0,0 +1,278 @@
<script lang="ts">
import { createPartitions } from '$lib/api/disk/disk';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Slider } from '$lib/components/ui/slider/index.js';
import * as Table from '$lib/components/ui/table';
import type { Disk } from '$lib/types/disk/disk';
import { handleAPIError } from '$lib/utils/http';
import { getTranslation } from '$lib/utils/i18n';
import { capitalizeFirstLetter } from '$lib/utils/string';
import Icon from '@iconify/svelte';
import humanFormat from 'human-format';
import { tick } from 'svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
interface Data {
open: boolean;
disk: Disk | null;
onCancel: () => void;
}
let { open, disk, onCancel }: Data = $props();
let newPartitions: { name: string; size: number }[] = $state([]);
let remainingSpace = $state(0);
let currentPartitionInput = $state('');
let currentPartition = $derived.by(() => {
try {
const parsed = humanFormat.parse.raw(currentPartitionInput);
return parsed.factor * parsed.value;
} catch (e) {
return 0;
}
});
$effect(() => {
if (disk) {
remainingSpace = calculateRemainingSpace(disk);
}
});
function removePartition(index: number) {
const removedPartition = newPartitions.splice(index, 1)[0];
remainingSpace += removedPartition.size;
}
async function savePartitions() {
if (disk) {
const sizes = newPartitions.map((partition) => Math.floor(partition.size));
const result = await createPartitions(`/dev/${disk.device}`, sizes);
if (result.status === 'success') {
let successMessage = '';
if (sizes.length === 1) {
successMessage = `${capitalizeFirstLetter(getTranslation('disk.partition', 'Partition'))}`;
} else {
successMessage = `${capitalizeFirstLetter(getTranslation('disk.partitions', 'Partitions'))}`;
}
successMessage += ` ${getTranslation('common.created', 'created')}`;
toast.success(successMessage);
} else {
handleAPIError(result);
let errorMessage =
capitalizeFirstLetter(getTranslation('common.error', 'Error')) +
getTranslation('common.creating', 'creating');
if (sizes.length === 1) {
errorMessage = `${capitalizeFirstLetter(getTranslation('disk.partition', 'Partition'))}`;
} else {
errorMessage = `${capitalizeFirstLetter(getTranslation('disk.partitions', 'Partitions'))}`;
}
}
newPartitions = [];
}
onCancel();
}
async function addPartition() {
if (currentPartition > 0) {
newPartitions.push({
name: `New Partition ${newPartitions.length + 1}`,
size: currentPartition
});
remainingSpace -= currentPartition;
currentPartition = 0;
currentPartitionInput = '0B';
await tick();
const table = document.getElementById('table-body');
if (table) {
table.scroll({
top: table.scrollHeight,
behavior: 'smooth'
});
}
}
}
function close() {
newPartitions = [];
remainingSpace = 0;
currentPartition = 0;
onCancel();
}
function calculateRemainingSpace(disk: Disk) {
if (!disk) return 0;
const usedSpace =
disk.partitions && disk.partitions.length > 0
? disk.partitions.reduce((total, partition) => total + partition.size, 0)
: 0;
let actual = disk.size - usedSpace;
if (actual > 128 * 1024 * 1024) {
actual = actual - 128 * 1024 * 1024;
}
return actual;
}
</script>
<Dialog.Root bind:open onOutsideClick={(e) => close()}>
<Dialog.Content
class="fixed left-1/2 top-1/2 w-[80%] -translate-x-1/2 -translate-y-1/2 transform gap-4 overflow-hidden p-5 lg:max-w-3xl"
>
<div class="flex items-center justify-between">
<Dialog.Header class="p-0">
<Dialog.Title>Create Partitions</Dialog.Title>
<Dialog.Description></Dialog.Description>
</Dialog.Header>
<div class="flex items-center gap-0.5">
<Button
size="sm"
variant="ghost"
class="h-8"
title={capitalizeFirstLetter(getTranslation('common.reset', 'Reset'))}
on:click={() => {
newPartitions = [];
remainingSpace = disk ? calculateRemainingSpace(disk) : 0;
currentPartition = 0;
currentPartitionInput = '';
}}
>
<Icon icon="radix-icons:reset" class="pointer-events-none h-4 w-4" />
<span class="sr-only"
>{capitalizeFirstLetter(getTranslation('common.reset', 'Reset'))}</span
>
</Button>
<Button
size="sm"
variant="ghost"
class="h-8"
title={capitalizeFirstLetter(getTranslation('common.close', 'Close'))}
onclick={() => close()}
>
<Icon icon="material-symbols:close-rounded" class="pointer-events-none h-4 w-4" />
<span class="sr-only"
>{capitalizeFirstLetter(getTranslation('common.close', 'Close'))}</span
>
</Button>
</div>
</div>
<div class="max-h-[300px] overflow-y-auto" id="table-body">
<Table.Root>
<Table.Header class="bg-background sticky top-0 z-10">
<Table.Row>
<Table.Head class="w-[200px]">Name</Table.Head>
<Table.Head class="w-[150px] text-right">Size</Table.Head>
<Table.Head class="w-[150px] text-right">Usage</Table.Head>
<Table.Head class="w-[100px] text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if disk && disk.partitions && disk.partitions.length > 0}
{#each disk.partitions as partition}
<Table.Row>
<Table.Cell>{partition.name}</Table.Cell>
<Table.Cell class="text-right">{humanFormat(partition.size)}</Table.Cell>
<Table.Cell class="text-right">{partition.usage}</Table.Cell>
<Table.Cell class="text-right">
<span class="text-muted-foreground text-xs italic">Existing</span>
</Table.Cell>
</Table.Row>
{/each}
{/if}
{#if newPartitions.length > 0}
{#each newPartitions as partition, index}
<Table.Row>
<Table.Cell>{partition.name}</Table.Cell>
<Table.Cell class="text-right">{humanFormat(partition.size)}</Table.Cell>
<Table.Cell class="text-right">-</Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost" class="h-8" onclick={() => removePartition(index)}>
<Icon icon="gg:trash" class="h-4 w-4" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
{/if}
{#if (!disk || !disk.partitions || disk.partitions.length === 0) && newPartitions.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="text-muted-foreground h-20 text-center">
No partitions created yet
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
<div class="space-y-2 border-t px-6 pt-4">
<div class="flex items-center gap-6">
<div class="flex-1">
<Slider
value={[currentPartition]}
max={remainingSpace}
step={0.1}
onValueChange={(e) => {
const value = Math.floor(e[0]);
currentPartition = value <= 0 ? 0 : value;
currentPartitionInput = humanFormat(currentPartition);
}}
/>
</div>
<Input
type="text"
class="h-8 w-24 text-right"
min="0"
max={remainingSpace}
bind:value={currentPartitionInput}
/>
<div class={remainingSpace > 0 ? '' : 'cursor-not-allowed'}>
<Button
class="h-8 whitespace-nowrap"
onclick={addPartition}
disabled={currentPartition <= 0}
>
{#if remainingSpace > 0}
Add Partition
{:else}
No space left
{/if}
</Button>
</div>
</div>
<div class="flex flex-col items-end gap-2">
<p class="text-muted-foreground text-sm">
Size: {humanFormat(currentPartition)}
</p>
<p class="text-muted-foreground text-sm">
Remaining space: {humanFormat(remainingSpace)}
</p>
</div>
</div>
{#if newPartitions.length > 0}
<div in:slide={{ duration: 200 }} out:slide={{ duration: 200 }}>
<Dialog.Footer class="flex justify-between gap-2 border-t px-6 py-4">
<div class="flex gap-2">
<Button size="sm" class="h-8" onclick={savePartitions}>Save Partitions</Button>
</div>
</Dialog.Footer>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,66 @@
<script lang="ts">
import { formatAction, formatStatus, getAuditLogs } from '$lib/api/info/audit';
import * as Table from '$lib/components/ui/table/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import type { AuditLog } from '$lib/types/info/audit';
import { convertDbTime } from '$lib/utils/time';
import { useQueries } from '@sveltestack/svelte-query';
import { onMount } from 'svelte';
const results = useQueries([
{
queryKey: ['auditLog'],
queryFn: async () => {
return await getAuditLogs();
},
refetchInterval: 1000,
keepPreviousData: true
}
]);
let logs = $derived($results[0].data as AuditLog);
</script>
<Tabs.Root value="cluster" class="flex h-full w-full flex-col">
<Tabs.Content value="cluster" class="flex h-full flex-col border">
<div class="flex h-full flex-col overflow-hidden">
<Table.Root class="w-full table-fixed border-collapse">
<Table.Header class="bg-background sticky top-0 z-[50] ">
<Table.Row class="dark:hover:bg-background ">
<Table.Head class="h-10 px-4 py-2 font-semibold text-black dark:text-white"
>Start Time</Table.Head
>
<Table.Head class="h-10 px-4 py-2 font-semibold text-black dark:text-white"
>End Time</Table.Head
>
<Table.Head class="h-10 px-4 py-2 font-semibold text-black dark:text-white"
>Node</Table.Head
>
<Table.Head class="h-10 px-4 py-2 font-semibold text-black dark:text-white"
>User</Table.Head
>
<Table.Head class="h-10 px-4 py-2 font-semibold text-black dark:text-white"
>Action</Table.Head
>
<Table.Head class="h-10 px-4 py-2 font-semibold text-black dark:text-white"
>Status</Table.Head
>
</Table.Row>
</Table.Header>
<Table.Body class="flex-grow overflow-auto pb-32">
{#each logs as log, i (i)}
<Table.Row>
<Table.Cell class="h-10 px-4 py-2">{convertDbTime(log.started)}</Table.Cell>
<Table.Cell class="h-10 px-4 py-2">{convertDbTime(log.ended)}</Table.Cell>
<Table.Cell class="h-10 px-4 py-2">{log.node}</Table.Cell>
<Table.Cell class="h-10 px-4 py-2">{log.user}@{log.authType}</Table.Cell>
<Table.Cell class="h-10 px-4 py-2">{formatAction(log.action)}</Table.Cell>
<Table.Cell class="h-10 px-4 py-2">{formatStatus(log.status)}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
</Tabs.Content>
</Tabs.Root>
@@ -0,0 +1,62 @@
<script lang="ts">
import { getVMs } from '$lib/api/vm/vm';
import { default as TreeView } from '$lib/components/custom/TreeView.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { hostname } from '$lib/stores/basic';
import type { VM } from '$lib/types/vm/vm';
import { getTranslation } from '$lib/utils/i18n';
import { capitalizeFirstLetter } from '$lib/utils/string';
import { useQueries } from '@sveltestack/svelte-query';
let openCategories: { [key: string]: boolean } = $state({});
let node = $hostname;
const toggleCategory = (label: string) => {
openCategories[label] = !openCategories[label];
};
const results = useQueries([
{
queryKey: ['vms-list'],
queryFn: async () => {
return await getVMs();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: [] as VM[],
refetchOnMount: 'always'
}
]);
const vms = $derived($results[0].data || []);
const tree = $derived([
{
label: capitalizeFirstLetter(getTranslation('common.datacenter', 'Data Center')),
icon: 'fa-solid:server',
children: [
{
label: node,
icon: 'mdi:dns',
href: `/${node}`,
children: vms.map((vm) => ({
label: `${vm.name} (${vm.vmId})`,
icon: 'material-symbols:monitor-outline',
href: `/${node}/vm/${vm.vmId}`
}))
}
]
}
]);
</script>
<div class="h-full overflow-y-auto px-1.5 pt-1">
<nav aria-label="Difuse-sidebar" class="menu thin-scrollbar w-full">
<ul>
<ScrollArea orientation="both" class="h-full w-full">
{#each tree as item}
<TreeView {item} onToggle={toggleCategory} bind:this={openCategories} />
{/each}
</ScrollArea>
</ul>
</nav>
</div>
@@ -0,0 +1,52 @@
<script lang="ts">
import Header from '$lib/components/custom/Header.svelte';
import * as Resizable from '$lib/components/ui/resizable';
import Terminal from '$lib/components/custom/Terminal.svelte';
import BottomPanel from '$lib/components/skeleton/BottomPanel.svelte';
import LeftPanel from '$lib/components/skeleton/LeftPanel.svelte';
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
</script>
<div class="flex min-h-screen w-full flex-col">
<Header />
<main class="flex flex-1 flex-col">
<div class="h-[95vh] w-full md:h-[96vh]">
<Resizable.PaneGroup
direction="vertical"
id="child-pane-auto"
autoSaveId="child-pane-auto-save"
>
<Resizable.Pane>
<Resizable.PaneGroup
direction="horizontal"
id="child-left-pane-auto"
autoSaveId="child-left-pane-auto-save"
>
<Resizable.Pane defaultSize={12}>
<LeftPanel />
</Resizable.Pane>
<Resizable.Handle withHandle />
<Resizable.Pane>
{@render children?.()}
</Resizable.Pane>
</Resizable.PaneGroup>
</Resizable.Pane>
<Resizable.Handle withHandle />
<Resizable.Pane class="h-full min-h-20" defaultSize={10}>
<BottomPanel />
</Resizable.Pane>
</Resizable.PaneGroup>
</div>
<Terminal />
</main>
</div>
@@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.ActionProps = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants(), className)}
{...restProps}
/>
@@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.CancelProps = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant: "outline" }), className)}
{...restProps}
/>
@@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
} = $props();
</script>
<AlertDialogPrimitive.Portal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-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 left-[50%] top-[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}
/>
</AlertDialogPrimitive.Portal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>
@@ -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="alert-dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -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-dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
data-slot="alert-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,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn("text-lg font-semibold", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
</script>
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
@@ -0,0 +1,39 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import Trigger from "./alert-dialog-trigger.svelte";
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte";
import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
const Root = AlertDialogPrimitive.Root;
const Portal = AlertDialogPrimitive.Portal;
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
};
@@ -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 whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium 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>
@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
@@ -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>
@@ -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>
@@ -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>
@@ -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("[.border-t]:pt-6 flex items-center px-6", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -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 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -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("font-semibold leading-none", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -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>
@@ -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,36 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>
@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};
@@ -0,0 +1,40 @@
<script lang="ts">
import type { Command as CommandPrimitive, Dialog as DialogPrimitive } from "bits-ui";
import type { Snippet } from "svelte";
import Command from "./command.svelte";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
open = $bindable(false),
ref = $bindable(null),
value = $bindable(""),
title = "Command Palette",
description = "Search for a command to run",
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
title?: string;
description?: string;
} = $props();
</script>
<Dialog.Root bind:open {...restProps}>
<Dialog.Header class="sr-only">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
</Dialog.Header>
<Dialog.Content class="overflow-hidden p-0" {portalProps}>
<Command
class="**:data-[slot=command-input-wrapper]:h-12 [&_[data-command-group]:not([hidden])_~[data-command-group]]:pt-0 [&_[data-command-group]]:px-2 [&_[data-command-input-wrapper]_svg]:h-5 [&_[data-command-input-wrapper]_svg]:w-5 [&_[data-command-input]]:h-12 [&_[data-command-item]]:px-2 [&_[data-command-item]]:py-3 [&_[data-command-item]_svg]:h-5 [&_[data-command-item]_svg]:w-5"
{...restProps}
bind:value
bind:ref
{children}
/>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.EmptyProps = $props();
</script>
<CommandPrimitive.Empty
bind:ref
data-slot="command-empty"
class={cn("py-6 text-center text-sm", className)}
{...restProps}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
import { Command as CommandPrimitive, useId } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
heading,
value,
...restProps
}: CommandPrimitive.GroupProps & {
heading?: string;
} = $props();
</script>
<CommandPrimitive.Group
bind:ref
data-slot="command-group"
class={cn("text-foreground overflow-hidden p-1", className)}
value={value ?? heading ?? `----${useId()}`}
{...restProps}
>
{#if heading}
<CommandPrimitive.GroupHeading
class="text-muted-foreground px-2 py-1.5 text-xs font-medium"
>
{heading}
</CommandPrimitive.GroupHeading>
{/if}
<CommandPrimitive.GroupItems {children} />
</CommandPrimitive.Group>
@@ -0,0 +1,26 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import SearchIcon from "@lucide/svelte/icons/search";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: CommandPrimitive.InputProps = $props();
</script>
<div class="flex h-9 items-center gap-2 border-b px-3" data-slot="command-input-wrapper">
<SearchIcon class="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
class={cn(
"placeholder:text-muted-foreground outline-hidden flex h-10 w-full rounded-md bg-transparent py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:ref
{...restProps}
bind:value
/>
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ItemProps = $props();
</script>
<CommandPrimitive.Item
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.LinkItemProps = $props();
</script>
<CommandPrimitive.LinkItem
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ListProps = $props();
</script>
<CommandPrimitive.List
bind:ref
data-slot="command-list"
class={cn("max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden", className)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.SeparatorProps = $props();
</script>
<CommandPrimitive.Separator
bind:ref
data-slot="command-separator"
class={cn("bg-border -mx-1 h-px", className)}
{...restProps}
/>
@@ -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<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="command-shortcut"
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,22 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: CommandPrimitive.RootProps = $props();
</script>
<CommandPrimitive.Root
bind:value
bind:ref
data-slot="command"
class={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...restProps}
/>
@@ -0,0 +1,40 @@
import { Command as CommandPrimitive } from "bits-ui";
import Root from "./command.svelte";
import Dialog from "./command-dialog.svelte";
import Empty from "./command-empty.svelte";
import Group from "./command-group.svelte";
import Item from "./command-item.svelte";
import Input from "./command-input.svelte";
import List from "./command-list.svelte";
import Separator from "./command-separator.svelte";
import Shortcut from "./command-shortcut.svelte";
import LinkItem from "./command-link-item.svelte";
const Loading = CommandPrimitive.Loading;
export {
Root,
Dialog,
Empty,
Group,
Item,
LinkItem,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Item as CommandItem,
LinkItem as CommandLinkItem,
Input as CommandInput,
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading,
};
@@ -0,0 +1,30 @@
<script lang="ts">
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { generateNanoId } from '$lib/utils/string';
interface Props {
label: string;
checked: boolean;
classes: string;
}
let {
label = '',
checked = $bindable(false),
classes = 'flex items-center gap-2'
}: Props = $props();
let nanoId = $state(generateNanoId(label));
</script>
<div class={classes}>
<Checkbox id={nanoId} bind:checked aria-labelledby={label} />
<Label
id={nanoId}
for={nanoId}
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{label}
</Label>
</div>
@@ -0,0 +1,145 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import * as Command from '$lib/components/ui/command/index.js';
import Label from '$lib/components/ui/label/label.svelte';
import * as Popover from '$lib/components/ui/popover/index.js';
import { cn } from '$lib/utils.js';
import Icon from '@iconify/svelte';
interface Props {
open: boolean;
label: string;
value: string | string[];
data: { value: string; label: string }[];
onValueChange?: (value: string | string[]) => void;
placeholder?: string;
disabled?: boolean;
classes?: string;
width?: string;
disallowEmpty?: boolean;
multiple?: boolean;
}
let {
open = $bindable(false),
label = '',
data = [],
onValueChange = () => {},
placeholder = '',
disabled = false,
classes = 'space-y-1',
width = 'w-1/2',
disallowEmpty = false,
multiple = false,
value = $bindable(multiple ? [] : '')
}: Props = $props();
let search = $state('');
const filteredData = $derived.by(() => {
if (!search) return data;
const q = search.toLowerCase();
return data.filter(
({ label, value }) => label.toLowerCase().includes(q) || value.toLowerCase().includes(q)
);
});
function selectItem(val: string) {
if (multiple) {
// start with a fresh array copy
const arr = Array.isArray(value) ? [...value] : [];
const idx = arr.indexOf(val);
if (idx >= 0) {
arr.splice(idx, 1);
} else {
arr.push(val);
}
value = arr;
onValueChange(arr);
// keep open=true so you can pick more
} else {
if (value === val && !disallowEmpty) {
value = '';
onValueChange('');
} else {
value = val;
onValueChange(val);
}
open = false;
}
search = '';
}
const selectedLabels = $derived.by(() => {
const vals = multiple ? (Array.isArray(value) ? value : []) : value ? [value] : [];
return data.filter((d) => vals.includes(d.value)).map((d) => d.label);
});
</script>
<div class={classes}>
<Label class="w-24 whitespace-nowrap text-sm" for={label.toLowerCase()}>
{label}
</Label>
<Popover.Root bind:open>
<Popover.Trigger>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
class="w-full flex-wrap justify-between gap-1"
{disabled}
>
{#if selectedLabels.length > 0}
{#each selectedLabels as lbl, i}
<span
class={multiple
? 'bg-secondary/100 rounded px-2 py-0.5 text-sm'
: 'rounded px-2 text-sm'}
>
{lbl}
</span>
{/each}
{:else}
<span class="opacity-50">{placeholder}</span>
{/if}
<Icon icon="lucide:chevrons-up-down" class="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>
</Popover.Trigger>
<Popover.Content class="{width} p-0">
<Command.Root shouldFilter={false}>
<Command.Input bind:value={search} placeholder={placeholder || 'Search...'} />
<Command.Empty>No data</Command.Empty>
<div class="max-h-64 overflow-y-auto">
<Command.Group>
{#each filteredData as element}
<Command.Item
value={element.value}
onSelect={() => selectItem(element.value)}
onkeydown={(e) => {
if (e.key === 'Enter') selectItem(element.value);
}}
>
<Icon
icon="lucide:check"
class={cn(
'mr-2 h-4 w-4',
multiple
? Array.isArray(value) && value.includes(element.value)
? 'opacity-100'
: 'opacity-0'
: value === element.value
? 'opacity-100'
: 'opacity-0'
)}
/>
{element.label}
</Command.Item>
{/each}
</Command.Group>
</div>
</Command.Root>
</Popover.Content>
</Popover.Root>
</div>
@@ -0,0 +1,49 @@
<script lang="ts">
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
import { generateNanoId } from '$lib/utils/string';
import type { FullAutoFill } from 'svelte/elements';
interface Props {
label: string;
value: string | number;
placeholder: string;
autocomplete?: FullAutoFill | null | undefined;
classes: string;
type?: string;
textAreaCLasses?: string;
disabled?: boolean;
}
let {
value = $bindable(''),
label = '',
placeholder = '',
autocomplete = 'off',
classes = 'space-y-1',
type = 'text',
textAreaCLasses = 'min-h-56',
disabled = false
}: Props = $props();
let nanoId = $state(generateNanoId(label));
</script>
<div class={`${classes}`}>
{#if label}
<Label for={nanoId}>{label}</Label>
{/if}
{#if type === 'textarea'}
<Textarea
class={textAreaCLasses}
id={nanoId}
{placeholder}
{autocomplete}
bind:value
{disabled}
/>
{:else}
<Input {type} id={nanoId} {placeholder} {autocomplete} bind:value {disabled} />
{/if}
</div>
@@ -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} />
@@ -0,0 +1,43 @@
<script lang="ts">
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
// import XIcon from '@lucide/svelte/icons/x';
import { Dialog as DialogPrimitive } from 'bits-ui';
import type { Snippet } from 'svelte';
import * as Dialog from './index.js';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<Dialog.Portal {...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 left-[50%] top-[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 rounded-xs focus:outline-hidden absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
>
<!-- <XIcon /> -->
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</Dialog.Portal>
@@ -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}
/>
@@ -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>
@@ -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-left", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -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,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 font-semibold leading-none", 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} />
@@ -0,0 +1,37 @@
import { Dialog as DialogPrimitive } from "bits-ui";
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";
const Root = DialogPrimitive.Root;
const Portal = DialogPrimitive.Portal;
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,
};
@@ -0,0 +1,41 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
{#if indeterminate}
<MinusIcon class="size-4" />
{:else}
<CheckIcon class={cn("size-4", !checked && "text-transparent")} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
sideOffset = 4,
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: DropdownMenuPrimitive.PortalProps;
} = $props();
</script>
<DropdownMenuPrimitive.Portal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
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-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md",
className
)}
{...restProps}
/>
</DropdownMenuPrimitive.Portal>
@@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>
@@ -0,0 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CircleIcon from "@lucide/svelte/icons/circle";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
{#if checked}
<CircleIcon class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>

Some files were not shown because too many files have changed in this diff Show More