mirror of
https://github.com/AlchemillaHQ/Sylve.git
synced 2026-06-14 00:46:34 +03:00
ui: add shadcn-svelte 5 components
This commit is contained in:
Generated
+1700
-42
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user