Initial dev-1.0 commit for TS and Shadcn migration

This commit is contained in:
LukeGus
2025-07-17 01:13:30 -05:00
commit 00a827df09
82 changed files with 45402 additions and 0 deletions

117
.github/workflows/docker-image.yml vendored Normal file
View File

@@ -0,0 +1,117 @@
name: Build and Push Docker Image
on:
push:
branches:
- development
paths-ignore:
- '**.md'
- '.gitignore'
workflow_dispatch:
inputs:
tag_name:
description: "Custom tag name for the Docker image"
required: false
default: ""
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
driver-opts: |
image=moby/buildkit:master
network=host
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: |
~/.npm
node_modules
*/*/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine Docker image tag
run: |
echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
if [ "${{ github.event.inputs.tag_name }}" == "" ]; then
IMAGE_TAG="${{ github.ref_name }}-development-latest"
else
IMAGE_TAG="${{ github.event.inputs.tag_name }}"
fi
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
- name: Build and Push Multi-Arch Docker Image
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ghcr.io/${{ env.REPO_OWNER }}/termix:${{ env.IMAGE_TAG }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
build-args: |
BUILDKIT_INLINE_CACHE=1
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
outputs: type=registry,compression=zstd,compression-level=19
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
- name: Notify via ntfy
if: success()
run: |
curl -d "Docker image build and push completed successfully for tag: ${{ env.IMAGE_TAG }}" \
https://ntfy.karmaa.site/termix-build
- name: Delete all untagged image versions
if: success()
uses: quartx-analytics/ghcr-cleaner@v1
with:
owner-type: user
token: ${{ secrets.GHCR_TOKEN }}
repository-owner: ${{ github.repository_owner }}
delete-untagged: true
- name: Cleanup Docker Images Locally
if: always()
run: |
docker image prune -af
docker system prune -af --volumes

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

62
README.md Normal file
View File

@@ -0,0 +1,62 @@
# Repo Stats
![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars)
![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks)
![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release)
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
#### Top Technologies
[![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#)
[![Javascript Badge](https://img.shields.io/badge/-Javascript-F0DB4F?style=flat-square&labelColor=black&logo=javascript&logoColor=F0DB4F)](#)
[![Nodejs Badge](https://img.shields.io/badge/-Nodejs-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#)
[![HTML Badge](https://img.shields.io/badge/-HTML-E34F26?style=flat-square&labelColor=black&logo=html5&logoColor=E34F26)](#)
[![Tailwind CSS Badge](https://img.shields.io/badge/-TailwindCSS-38B2AC?style=flat-square&labelColor=black&logo=tailwindcss&logoColor=38B2AC)](#)
[![Docker Badge](https://img.shields.io/badge/-Docker-2496ED?style=flat-square&labelColor=black&logo=docker&logoColor=2496ED)](#)
[![MongoDB Badge](https://img.shields.io/badge/-MongoDB-47A248?style=flat-square&labelColor=black&logo=mongodb&logoColor=47A248)](#)
[![MUI Joy Badge](https://img.shields.io/badge/-MUI%20Joy-007FFF?style=flat-square&labelColor=black&logo=mui&logoColor=007FFF)](#)
<br />
<p align="center">
<a href="https://github.com/LukeGus/Termix">
<img alt="Termix Banner" src=../../Termix/repo-images/TermixLogo.png style="width: 125px; height: auto;"> </a>
</p>
If you would like, you can support the project here!\
[![PayPal](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://paypal.me/LukeGustafson803)
# Overview
Termix is an open-source forever free self-hosted Homepage (other protocols planned, see [Planned Features](#planned-features)) server management panel inspired by [Nexterm](https://github.com/gnmyt/Nexterm). Its purpose is to provide an all-in-one docker-hosted web solution to manage your servers in one easy place. I'm using this project to help me learn [React](https://github.com/facebook/react), [Vite](https://github.com/vitejs/vite-plugin-react), and [Docker](https://www.docker.com) but also because I could never settle on a server management software that I enjoyed to use.
> [!WARNING]
> This app is in the VERY early stages of development. Expect bugs, data loss, and unexplainable issues! For that reason, I recommend you securely tunnel your connection to Termix through a VPN.
# Features
- Homepage
- Split Screen (Up to 4) & Tab System
- User Authentication
- Save Hosts (and easily view, connect, and manage them)
- SSHTerminal Themes
# Planned Features
- VNC
- RDP
- SFTP (build in file transfer)
- ChatGPT/Ollama Integration (for commands)
- Apps (like notes, AI, etc)
- User Management (roles, permissions, etc.)
- Homepage Tunneling
- More Authentication Methods
- More Security Features (like 2FA, etc.)
# Installation
Visit the Termix [Wiki](https://github.com/LukeGus/Termix/wiki) for information on how to install Termix. You can also use these links to go directly to guide. [Docker](https://github.com/LukeGus/Termix/wiki/Docker) or [Manual](https://github.com/LukeGus/Termix/wiki/Manual).
# Support
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo. If you would like to support me financially, you can on [Paypal](https://paypal.me/LukeGustafson803).
# Show-off
![Demo Image](../../Termix/repo-images/DemoImage1.png)
![Demo Image](../../Termix/repo-images/DemoImage2.png)
# License
Distributed under the MIT license. See LICENSE for more information.

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

81
docker/Dockerfile Normal file
View File

@@ -0,0 +1,81 @@
# Stage 1: Install dependencies and build frontend
FROM node:18-alpine AS deps
WORKDIR /app
# Copy dependency files
COPY package*.json ./
# Install dependencies with caching
RUN npm ci --force && \
npm cache clean --force
# Stage 2: Build frontend
FROM deps AS frontend-builder
WORKDIR /app
# Copy source files
COPY . .
# Build frontend
RUN npm run build
# Stage 3: Production dependencies
FROM node:18-alpine AS production-deps
WORKDIR /app
# Copy only production dependency files
COPY package*.json ./
# Install only production dependencies
RUN npm ci --only=production --ignore-scripts --force && \
npm cache clean --force
# Stage 4: Build native modules
FROM node:18-alpine AS native-builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache python3 make g++
# Copy dependency files
COPY package*.json ./
# Install only the native modules we need
RUN npm ci --only=production bcrypt better-sqlite3 --force && \
npm cache clean --force
# Stage 5: Final image
FROM node:18-alpine
ENV DATA_DIR=/app/data \
PORT=8080
# Install dependencies in a single layer
RUN apk add --no-cache nginx gettext su-exec && \
mkdir -p /app/data && \
chown -R node:node /app/data
# Setup nginx and frontend
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
RUN chown -R nginx:nginx /usr/share/nginx/html
# Setup backend
WORKDIR /app
COPY package*.json ./
# Copy production dependencies and native modules
COPY --from=production-deps /app/node_modules /app/node_modules
COPY --from=native-builder /app/node_modules/bcrypt /app/node_modules/bcrypt
COPY --from=native-builder /app/node_modules/better-sqlite3 /app/node_modules/better-sqlite3
# Copy backend source
COPY src/backend/ ./src/backend/
RUN chown -R node:node /app
VOLUME ["/app/data"]
# Expose ports
EXPOSE ${PORT} 8081 8082 8083 8084 8085
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
CMD ["/entrypoint.sh"]

17
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
termix:
#image: ghcr.io/lukegus/termix:latest
image: ghcr.io/lukegus/termix:dev-0.3-development-latest
container_name: termix
restart: unless-stopped
ports:
- "3800:8080"
volumes:
- termix-data:/app/data
environment:
# Generate random salt here https://www.lastpass.com/features/password-generator (max 32 characters, include all characters for settings)
SALT: "2v.F7!6a!jIzmJsu|[)h61$ZMXs;,i+~"
volumes:
termix-data:
driver: local

30
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,30 @@
#!/bin/sh
set -e
export PORT=${PORT:-8080}
echo "Configuring web UI to run on port: $PORT"
envsubst '${PORT}' < /etc/nginx/nginx.conf > /etc/nginx/nginx.conf.tmp
mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf
mkdir -p /app/data
chown -R node:node /app/data
chmod 755 /app/data
echo "Starting nginx..."
nginx
# Start backend services
echo "Starting backend services..."
cd /app
export NODE_ENV=production
if command -v su-exec > /dev/null 2>&1; then
su-exec node node src/backend/starter.cjs
else
su -s /bin/sh node -c "node src/backend/starter.cjs"
fi
echo "All services started"
tail -f /dev/null

91
docker/nginx.conf Normal file
View File

@@ -0,0 +1,91 @@
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen ${PORT};
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
location /database.io/ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh.io/ {
proxy_pass http://127.0.0.1:8082;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /rdp.io/ {
proxy_pass http://127.0.0.1:8083;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /vnc.io/ {
proxy_pass http://127.0.0.1:8084;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /sftp.io/ {
proxy_pass http://127.0.0.1:8085;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}

53
docs/.github/workflows/deploy_docs.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Deploy VitePress site to Pages
on:
push:
branches: [docs]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Install dependencies
run: yarn install
- name: Build with VitePress
run: yarn run docs:build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: .vitepress/dist
name: github-pages
retention-days: 1
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
name: Deploy
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

1
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/node_modules/

View File

@@ -0,0 +1,275 @@
import {
useMediaQuery
} from "./chunk-6F5JWMBQ.js";
import {
computed,
ref,
shallowRef,
watch
} from "./chunk-VJWGEPT5.js";
// node_modules/vitepress/dist/client/theme-default/index.js
import "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/styles/fonts.css";
// node_modules/vitepress/dist/client/theme-default/without-fonts.js
import "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/styles/vars.css";
import "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/styles/base.css";
import "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/styles/icons.css";
import "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/styles/utils.css";
import "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/styles/components/custom-block.css";
import "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code.css";
import "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code-group.css";
import "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/styles/components/vp-doc.css";
import "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/styles/components/vp-sponsor.css";
import VPBadge from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue";
import Layout from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/Layout.vue";
import { default as default2 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue";
import { default as default3 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPButton.vue";
import { default as default4 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPDocAsideSponsors.vue";
import { default as default5 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPFeatures.vue";
import { default as default6 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPHomeContent.vue";
import { default as default7 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPHomeFeatures.vue";
import { default as default8 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPHomeHero.vue";
import { default as default9 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPHomeSponsors.vue";
import { default as default10 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPImage.vue";
import { default as default11 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPLink.vue";
import { default as default12 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPNavBarSearch.vue";
import { default as default13 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPSocialLink.vue";
import { default as default14 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPSocialLinks.vue";
import { default as default15 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPSponsors.vue";
import { default as default16 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPTeamMembers.vue";
import { default as default17 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPTeamPage.vue";
import { default as default18 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageSection.vue";
import { default as default19 } from "C:/Users/Luke/Documents/Personal Projects/Termix/docs/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageTitle.vue";
// node_modules/vitepress/dist/client/theme-default/composables/local-nav.js
import { onContentUpdated } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/composables/outline.js
import { getScrollOffset } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/support/utils.js
import { withBase } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/composables/data.js
import { useData as useData$ } from "vitepress";
var useData = useData$;
// node_modules/vitepress/dist/client/theme-default/support/utils.js
function ensureStartingSlash(path) {
return path.startsWith("/") ? path : `/${path}`;
}
// node_modules/vitepress/dist/client/theme-default/support/sidebar.js
function getSidebar(_sidebar, path) {
if (Array.isArray(_sidebar))
return addBase(_sidebar);
if (_sidebar == null)
return [];
path = ensureStartingSlash(path);
const dir = Object.keys(_sidebar).sort((a, b) => {
return b.split("/").length - a.split("/").length;
}).find((dir2) => {
return path.startsWith(ensureStartingSlash(dir2));
});
const sidebar = dir ? _sidebar[dir] : [];
return Array.isArray(sidebar) ? addBase(sidebar) : addBase(sidebar.items, sidebar.base);
}
function getSidebarGroups(sidebar) {
const groups = [];
let lastGroupIndex = 0;
for (const index in sidebar) {
const item = sidebar[index];
if (item.items) {
lastGroupIndex = groups.push(item);
continue;
}
if (!groups[lastGroupIndex]) {
groups.push({ items: [] });
}
groups[lastGroupIndex].items.push(item);
}
return groups;
}
function addBase(items, _base) {
return [...items].map((_item) => {
const item = { ..._item };
const base = item.base || _base;
if (base && item.link)
item.link = base + item.link;
if (item.items)
item.items = addBase(item.items, base);
return item;
});
}
// node_modules/vitepress/dist/client/theme-default/composables/sidebar.js
function useSidebar() {
const { frontmatter, page, theme: theme2 } = useData();
const is960 = useMediaQuery("(min-width: 960px)");
const isOpen = ref(false);
const _sidebar = computed(() => {
const sidebarConfig = theme2.value.sidebar;
const relativePath = page.value.relativePath;
return sidebarConfig ? getSidebar(sidebarConfig, relativePath) : [];
});
const sidebar = ref(_sidebar.value);
watch(_sidebar, (next, prev) => {
if (JSON.stringify(next) !== JSON.stringify(prev))
sidebar.value = _sidebar.value;
});
const hasSidebar = computed(() => {
return frontmatter.value.sidebar !== false && sidebar.value.length > 0 && frontmatter.value.layout !== "home";
});
const leftAside = computed(() => {
if (hasAside)
return frontmatter.value.aside == null ? theme2.value.aside === "left" : frontmatter.value.aside === "left";
return false;
});
const hasAside = computed(() => {
if (frontmatter.value.layout === "home")
return false;
if (frontmatter.value.aside != null)
return !!frontmatter.value.aside;
return theme2.value.aside !== false;
});
const isSidebarEnabled = computed(() => hasSidebar.value && is960.value);
const sidebarGroups = computed(() => {
return hasSidebar.value ? getSidebarGroups(sidebar.value) : [];
});
function open() {
isOpen.value = true;
}
function close() {
isOpen.value = false;
}
function toggle() {
isOpen.value ? close() : open();
}
return {
isOpen,
sidebar,
sidebarGroups,
hasSidebar,
hasAside,
leftAside,
isSidebarEnabled,
open,
close,
toggle
};
}
// node_modules/vitepress/dist/client/theme-default/composables/outline.js
var ignoreRE = /\b(?:VPBadge|header-anchor|footnote-ref|ignore-header)\b/;
var resolvedHeaders = [];
function getHeaders(range) {
const headers = [
...document.querySelectorAll(".VPDoc :where(h1,h2,h3,h4,h5,h6)")
].filter((el) => el.id && el.hasChildNodes()).map((el) => {
const level = Number(el.tagName[1]);
return {
element: el,
title: serializeHeader(el),
link: "#" + el.id,
level
};
});
return resolveHeaders(headers, range);
}
function serializeHeader(h) {
let ret = "";
for (const node of h.childNodes) {
if (node.nodeType === 1) {
if (ignoreRE.test(node.className))
continue;
ret += node.textContent;
} else if (node.nodeType === 3) {
ret += node.textContent;
}
}
return ret.trim();
}
function resolveHeaders(headers, range) {
if (range === false) {
return [];
}
const levelsRange = (typeof range === "object" && !Array.isArray(range) ? range.level : range) || 2;
const [high, low] = typeof levelsRange === "number" ? [levelsRange, levelsRange] : levelsRange === "deep" ? [2, 6] : levelsRange;
return buildTree(headers, high, low);
}
function buildTree(data, min, max) {
resolvedHeaders.length = 0;
const result = [];
const stack = [];
data.forEach((item) => {
const node = { ...item, children: [] };
let parent = stack[stack.length - 1];
while (parent && parent.level >= node.level) {
stack.pop();
parent = stack[stack.length - 1];
}
if (node.element.classList.contains("ignore-header") || parent && "shouldIgnore" in parent) {
stack.push({ level: node.level, shouldIgnore: true });
return;
}
if (node.level > max || node.level < min)
return;
resolvedHeaders.push({ element: node.element, link: node.link });
if (parent)
parent.children.push(node);
else
result.push(node);
stack.push(node);
});
return result;
}
// node_modules/vitepress/dist/client/theme-default/composables/local-nav.js
function useLocalNav() {
const { theme: theme2, frontmatter } = useData();
const headers = shallowRef([]);
const hasLocalNav = computed(() => {
return headers.value.length > 0;
});
onContentUpdated(() => {
headers.value = getHeaders(frontmatter.value.outline ?? theme2.value.outline);
});
return {
headers,
hasLocalNav
};
}
// node_modules/vitepress/dist/client/theme-default/without-fonts.js
var theme = {
Layout,
enhanceApp: ({ app }) => {
app.component("Badge", VPBadge);
}
};
var without_fonts_default = theme;
export {
default2 as VPBadge,
default3 as VPButton,
default4 as VPDocAsideSponsors,
default5 as VPFeatures,
default6 as VPHomeContent,
default7 as VPHomeFeatures,
default8 as VPHomeHero,
default9 as VPHomeSponsors,
default10 as VPImage,
default11 as VPLink,
default12 as VPNavBarSearch,
default13 as VPSocialLink,
default14 as VPSocialLinks,
default15 as VPSponsors,
default16 as VPTeamMembers,
default17 as VPTeamPage,
default18 as VPTeamPageSection,
default19 as VPTeamPageTitle,
without_fonts_default as default,
useLocalNav,
useSidebar
};
//# sourceMappingURL=@theme_index.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,58 @@
{
"hash": "44a0f075",
"configHash": "41819f4d",
"lockfileHash": "db040343",
"browserHash": "3d4b0518",
"optimized": {
"vue": {
"src": "../../../node_modules/vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "b9adcfb0",
"needsInterop": false
},
"vitepress > @vue/devtools-api": {
"src": "../../../node_modules/@vue/devtools-api/dist/index.js",
"file": "vitepress___@vue_devtools-api.js",
"fileHash": "54f476e8",
"needsInterop": false
},
"vitepress > @vueuse/core": {
"src": "../../../node_modules/@vueuse/core/index.mjs",
"file": "vitepress___@vueuse_core.js",
"fileHash": "9e36f6ef",
"needsInterop": false
},
"vitepress > @vueuse/integrations/useFocusTrap": {
"src": "../../../node_modules/@vueuse/integrations/useFocusTrap.mjs",
"file": "vitepress___@vueuse_integrations_useFocusTrap.js",
"fileHash": "a04cdc76",
"needsInterop": false
},
"vitepress > mark.js/src/vanilla.js": {
"src": "../../../node_modules/mark.js/src/vanilla.js",
"file": "vitepress___mark__js_src_vanilla__js.js",
"fileHash": "0d42ca90",
"needsInterop": false
},
"vitepress > minisearch": {
"src": "../../../node_modules/minisearch/dist/es/index.js",
"file": "vitepress___minisearch.js",
"fileHash": "42d278a1",
"needsInterop": false
},
"@theme/index": {
"src": "../../../node_modules/vitepress/dist/client/theme-default/index.js",
"file": "@theme_index.js",
"fileHash": "fb8354da",
"needsInterop": false
}
},
"chunks": {
"chunk-6F5JWMBQ": {
"file": "chunk-6F5JWMBQ.js"
},
"chunk-VJWGEPT5": {
"file": "chunk-VJWGEPT5.js"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,583 @@
import {
DefaultMagicKeysAliasMap,
StorageSerializers,
TransitionPresets,
assert,
breakpointsAntDesign,
breakpointsBootstrapV5,
breakpointsElement,
breakpointsMasterCss,
breakpointsPrimeFlex,
breakpointsQuasar,
breakpointsSematic,
breakpointsTailwind,
breakpointsVuetify,
breakpointsVuetifyV2,
breakpointsVuetifyV3,
bypassFilter,
camelize,
clamp,
cloneFnJSON,
computedAsync,
computedEager,
computedInject,
computedWithControl,
containsProp,
controlledRef,
createEventHook,
createFetch,
createFilterWrapper,
createGlobalState,
createInjectionState,
createRef,
createReusableTemplate,
createSharedComposable,
createSingletonPromise,
createTemplatePromise,
createUnrefFn,
customStorageEventName,
debounceFilter,
defaultDocument,
defaultLocation,
defaultNavigator,
defaultWindow,
executeTransition,
extendRef,
formatDate,
formatTimeAgo,
get,
getLifeCycleTarget,
getSSRHandler,
hasOwn,
hyphenate,
identity,
increaseWithUnit,
injectLocal,
invoke,
isClient,
isDef,
isDefined,
isIOS,
isObject,
isWorker,
makeDestructurable,
mapGamepadToXbox360Controller,
noop,
normalizeDate,
notNullish,
now,
objectEntries,
objectOmit,
objectPick,
onClickOutside,
onElementRemoval,
onKeyDown,
onKeyPressed,
onKeyStroke,
onKeyUp,
onLongPress,
onStartTyping,
pausableFilter,
promiseTimeout,
provideLocal,
provideSSRWidth,
pxValue,
rand,
reactify,
reactifyObject,
reactiveComputed,
reactiveOmit,
reactivePick,
refAutoReset,
refDebounced,
refDefault,
refThrottled,
refWithControl,
resolveRef,
resolveUnref,
set,
setSSRHandler,
syncRef,
syncRefs,
templateRef,
throttleFilter,
timestamp,
toArray,
toReactive,
toRef,
toRefs,
toValue,
tryOnBeforeMount,
tryOnBeforeUnmount,
tryOnMounted,
tryOnScopeDispose,
tryOnUnmounted,
unrefElement,
until,
useActiveElement,
useAnimate,
useArrayDifference,
useArrayEvery,
useArrayFilter,
useArrayFind,
useArrayFindIndex,
useArrayFindLast,
useArrayIncludes,
useArrayJoin,
useArrayMap,
useArrayReduce,
useArraySome,
useArrayUnique,
useAsyncQueue,
useAsyncState,
useBase64,
useBattery,
useBluetooth,
useBreakpoints,
useBroadcastChannel,
useBrowserLocation,
useCached,
useClipboard,
useClipboardItems,
useCloned,
useColorMode,
useConfirmDialog,
useCountdown,
useCounter,
useCssVar,
useCurrentElement,
useCycleList,
useDark,
useDateFormat,
useDebounceFn,
useDebouncedRefHistory,
useDeviceMotion,
useDeviceOrientation,
useDevicePixelRatio,
useDevicesList,
useDisplayMedia,
useDocumentVisibility,
useDraggable,
useDropZone,
useElementBounding,
useElementByPoint,
useElementHover,
useElementSize,
useElementVisibility,
useEventBus,
useEventListener,
useEventSource,
useEyeDropper,
useFavicon,
useFetch,
useFileDialog,
useFileSystemAccess,
useFocus,
useFocusWithin,
useFps,
useFullscreen,
useGamepad,
useGeolocation,
useIdle,
useImage,
useInfiniteScroll,
useIntersectionObserver,
useInterval,
useIntervalFn,
useKeyModifier,
useLastChanged,
useLocalStorage,
useMagicKeys,
useManualRefHistory,
useMediaControls,
useMediaQuery,
useMemoize,
useMemory,
useMounted,
useMouse,
useMouseInElement,
useMousePressed,
useMutationObserver,
useNavigatorLanguage,
useNetwork,
useNow,
useObjectUrl,
useOffsetPagination,
useOnline,
usePageLeave,
useParallax,
useParentElement,
usePerformanceObserver,
usePermission,
usePointer,
usePointerLock,
usePointerSwipe,
usePreferredColorScheme,
usePreferredContrast,
usePreferredDark,
usePreferredLanguages,
usePreferredReducedMotion,
usePreferredReducedTransparency,
usePrevious,
useRafFn,
useRefHistory,
useResizeObserver,
useSSRWidth,
useScreenOrientation,
useScreenSafeArea,
useScriptTag,
useScroll,
useScrollLock,
useSessionStorage,
useShare,
useSorted,
useSpeechRecognition,
useSpeechSynthesis,
useStepper,
useStorage,
useStorageAsync,
useStyleTag,
useSupported,
useSwipe,
useTemplateRefsList,
useTextDirection,
useTextSelection,
useTextareaAutosize,
useThrottleFn,
useThrottledRefHistory,
useTimeAgo,
useTimeout,
useTimeoutFn,
useTimeoutPoll,
useTimestamp,
useTitle,
useToNumber,
useToString,
useToggle,
useTransition,
useUrlSearchParams,
useUserMedia,
useVModel,
useVModels,
useVibrate,
useVirtualList,
useWakeLock,
useWebNotification,
useWebSocket,
useWebWorker,
useWebWorkerFn,
useWindowFocus,
useWindowScroll,
useWindowSize,
watchArray,
watchAtMost,
watchDebounced,
watchDeep,
watchIgnorable,
watchImmediate,
watchOnce,
watchPausable,
watchThrottled,
watchTriggerable,
watchWithFilter,
whenever
} from "./chunk-6F5JWMBQ.js";
import "./chunk-VJWGEPT5.js";
export {
DefaultMagicKeysAliasMap,
StorageSerializers,
TransitionPresets,
assert,
computedAsync as asyncComputed,
refAutoReset as autoResetRef,
breakpointsAntDesign,
breakpointsBootstrapV5,
breakpointsElement,
breakpointsMasterCss,
breakpointsPrimeFlex,
breakpointsQuasar,
breakpointsSematic,
breakpointsTailwind,
breakpointsVuetify,
breakpointsVuetifyV2,
breakpointsVuetifyV3,
bypassFilter,
camelize,
clamp,
cloneFnJSON,
computedAsync,
computedEager,
computedInject,
computedWithControl,
containsProp,
computedWithControl as controlledComputed,
controlledRef,
createEventHook,
createFetch,
createFilterWrapper,
createGlobalState,
createInjectionState,
reactify as createReactiveFn,
createRef,
createReusableTemplate,
createSharedComposable,
createSingletonPromise,
createTemplatePromise,
createUnrefFn,
customStorageEventName,
debounceFilter,
refDebounced as debouncedRef,
watchDebounced as debouncedWatch,
defaultDocument,
defaultLocation,
defaultNavigator,
defaultWindow,
computedEager as eagerComputed,
executeTransition,
extendRef,
formatDate,
formatTimeAgo,
get,
getLifeCycleTarget,
getSSRHandler,
hasOwn,
hyphenate,
identity,
watchIgnorable as ignorableWatch,
increaseWithUnit,
injectLocal,
invoke,
isClient,
isDef,
isDefined,
isIOS,
isObject,
isWorker,
makeDestructurable,
mapGamepadToXbox360Controller,
noop,
normalizeDate,
notNullish,
now,
objectEntries,
objectOmit,
objectPick,
onClickOutside,
onElementRemoval,
onKeyDown,
onKeyPressed,
onKeyStroke,
onKeyUp,
onLongPress,
onStartTyping,
pausableFilter,
watchPausable as pausableWatch,
promiseTimeout,
provideLocal,
provideSSRWidth,
pxValue,
rand,
reactify,
reactifyObject,
reactiveComputed,
reactiveOmit,
reactivePick,
refAutoReset,
refDebounced,
refDefault,
refThrottled,
refWithControl,
resolveRef,
resolveUnref,
set,
setSSRHandler,
syncRef,
syncRefs,
templateRef,
throttleFilter,
refThrottled as throttledRef,
watchThrottled as throttledWatch,
timestamp,
toArray,
toReactive,
toRef,
toRefs,
toValue,
tryOnBeforeMount,
tryOnBeforeUnmount,
tryOnMounted,
tryOnScopeDispose,
tryOnUnmounted,
unrefElement,
until,
useActiveElement,
useAnimate,
useArrayDifference,
useArrayEvery,
useArrayFilter,
useArrayFind,
useArrayFindIndex,
useArrayFindLast,
useArrayIncludes,
useArrayJoin,
useArrayMap,
useArrayReduce,
useArraySome,
useArrayUnique,
useAsyncQueue,
useAsyncState,
useBase64,
useBattery,
useBluetooth,
useBreakpoints,
useBroadcastChannel,
useBrowserLocation,
useCached,
useClipboard,
useClipboardItems,
useCloned,
useColorMode,
useConfirmDialog,
useCountdown,
useCounter,
useCssVar,
useCurrentElement,
useCycleList,
useDark,
useDateFormat,
refDebounced as useDebounce,
useDebounceFn,
useDebouncedRefHistory,
useDeviceMotion,
useDeviceOrientation,
useDevicePixelRatio,
useDevicesList,
useDisplayMedia,
useDocumentVisibility,
useDraggable,
useDropZone,
useElementBounding,
useElementByPoint,
useElementHover,
useElementSize,
useElementVisibility,
useEventBus,
useEventListener,
useEventSource,
useEyeDropper,
useFavicon,
useFetch,
useFileDialog,
useFileSystemAccess,
useFocus,
useFocusWithin,
useFps,
useFullscreen,
useGamepad,
useGeolocation,
useIdle,
useImage,
useInfiniteScroll,
useIntersectionObserver,
useInterval,
useIntervalFn,
useKeyModifier,
useLastChanged,
useLocalStorage,
useMagicKeys,
useManualRefHistory,
useMediaControls,
useMediaQuery,
useMemoize,
useMemory,
useMounted,
useMouse,
useMouseInElement,
useMousePressed,
useMutationObserver,
useNavigatorLanguage,
useNetwork,
useNow,
useObjectUrl,
useOffsetPagination,
useOnline,
usePageLeave,
useParallax,
useParentElement,
usePerformanceObserver,
usePermission,
usePointer,
usePointerLock,
usePointerSwipe,
usePreferredColorScheme,
usePreferredContrast,
usePreferredDark,
usePreferredLanguages,
usePreferredReducedMotion,
usePreferredReducedTransparency,
usePrevious,
useRafFn,
useRefHistory,
useResizeObserver,
useSSRWidth,
useScreenOrientation,
useScreenSafeArea,
useScriptTag,
useScroll,
useScrollLock,
useSessionStorage,
useShare,
useSorted,
useSpeechRecognition,
useSpeechSynthesis,
useStepper,
useStorage,
useStorageAsync,
useStyleTag,
useSupported,
useSwipe,
useTemplateRefsList,
useTextDirection,
useTextSelection,
useTextareaAutosize,
refThrottled as useThrottle,
useThrottleFn,
useThrottledRefHistory,
useTimeAgo,
useTimeout,
useTimeoutFn,
useTimeoutPoll,
useTimestamp,
useTitle,
useToNumber,
useToString,
useToggle,
useTransition,
useUrlSearchParams,
useUserMedia,
useVModel,
useVModels,
useVibrate,
useVirtualList,
useWakeLock,
useWebNotification,
useWebSocket,
useWebWorker,
useWebWorkerFn,
useWindowFocus,
useWindowScroll,
useWindowSize,
watchArray,
watchAtMost,
watchDebounced,
watchDeep,
watchIgnorable,
watchImmediate,
watchOnce,
watchPausable,
watchThrottled,
watchTriggerable,
watchWithFilter,
whenever
};
//# sourceMappingURL=vitepress___@vueuse_core.js.map

View File

@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

343
docs/.vitepress/cache/deps/vue.js vendored Normal file
View File

@@ -0,0 +1,343 @@
import {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBaseVNode,
createBlock,
createCommentVNode,
createElementBlock,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
} from "./chunk-VJWGEPT5.js";
export {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBlock,
createCommentVNode,
createElementBlock,
createBaseVNode as createElementVNode,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
};
//# sourceMappingURL=vue.js.map

7
docs/.vitepress/cache/deps/vue.js.map vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View File

@@ -0,0 +1,43 @@
import { defineConfig } from 'vitepress'
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "Termix",
description: "Doccumentation",
lastUpdated: true,
cleanUrls: true,
metaChunk: true,
base: '/',
head: [['link', { rel: 'icon', href: '/favicon.ico' }]],
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
nav: [
{ text: 'Home', link: '/' },
{ text: 'Docs', link: '/docs' }
],
search: {
provider: "local",
},
footer: {
message: "Distributed under the MIT License",
copyright: "© 2025 Luke Gustafson",
},
sidebar: [
{
text: 'Examples',
items: [
{ text: 'Docs', link: '/docs' },
{ text: 'GitHub', link: 'https://github.com/LukeGus/Termix' }
]
}
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/LukeGus/Termix' },
{ icon: "discord", link: "https://discord.gg/jVQGdvHDrf" },
]
}
})

125
docs/docs.md Normal file
View File

@@ -0,0 +1,125 @@
# Termix Documentation
Termix is a powerful web-based Homepage terminal emulator that allows you to connect to your servers directly from your browser. With features like split screen, tab system, and saved hosts, Termix makes server management easier than ever.
## Installation
Termix can be installed using Docker, Docker Compose, or manually. Choose the method that works best for your environment.
### Docker Installation
The simplest way to get Termix up and running is with Docker:
```bash
# Create a persistent volume for MongoDB data
docker volume create mongodb-data
# Run the Termix container
docker run -d \
--name termix \
--restart unless-stopped \
-p 8080:8080 \
-v mongodb-data:/data/db \
-e SALT="2v.F7!6a!jIzmJsu|[)h61$ZMXs;,i+~" \
ghcr.io/lukegus/termix:latest
```
::: warning
Make sure to replace the SALT value with your own secure random string, using all characters and a maximum length of 32 characters. You can generate one at LastPass Password Generator.
:::
### Docker Compose Installation
For a more comprehensive setup, you can use Docker Compose:
```yaml
services:
termix:
image: ghcr.io/lukegus/termix:latest
container_name: termix
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- termix-data:/app/data
environment:
# Generate random salt here https://www.lastpass.com/features/password-generator
# (max 32 characters, include all characters for settings)
SALT: "2v.F7!6a!jIzmJsu|[)h61$ZMXs;,i+~"
PORT: "8080"
volumes:
termix-data:
driver: local
```
To start the container, run:
```bash
docker-compose up -d
```
### Manual Installation
If you prefer a manual installation, follow these steps:
#### Required Packages
- NPM
- NodeJS
- MongoDB
#### Installation Steps
1. Clone the repository:
```bash
git clone https://github.com/LukeGus/Termix.git
cd Termix
```
2. Install dependencies and build the project:
```bash
npm install
npm run build
```
3. Start the application:
```bash
npm run start
```
::: tip
For production environments, we recommend running the website via Nginx. See the Nginx configuration in the Docker directory of the repository.
:::
4. Start the backend services. Navigate to the backend directory:
```bash
cd src/backend
node database.cjs
node ssh.cjs
```
This will start the WebSocket services on ports 8081 and 8082.
## Usage
Once installed, Termix will be available at `http://localhost:8080` (or whichever port you configured).
1. Create an account or log in
2. Add your Homepage connection details
3. Connect to your servers
4. Enjoy the terminal experience right in your browser!
## Security Considerations
- Always use a strong, unique SALT value
- Keep your server up to date with the latest Termix releases
- Consider using key-based authentication rather than passwords
- For production deployments, set up HTTPS using a reverse proxy
## Troubleshooting
If you encounter any issues:
1. Check the container logs: `docker logs termix`
2. Ensure the correct ports are exposed and not blocked by a firewall
3. Check the GitHub repository for known issues or to file a new one

40
docs/index.md Normal file
View File

@@ -0,0 +1,40 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
hero:
name: "Termix"
text: "Web-based Homepage Client"
tagline: Clientless web-based Homepage terminal emulator that stores and manages your connection details
image:
src: /logo512.webp
alt: Termix
actions:
- theme: brand
text: Get Started
link: /docs
- theme: alt
text: GitHub
link: 'https://github.com/LukeGus/Termix'
features:
- icon: 📡
title: Homepage
details: Connect to any Homepage server directly from your browser with no client installation needed. Supports key-based and password authentication.
- icon: 🗂️
title: Split Screen & Tab System
details: View up to 4 terminals simultaneously with the split screen feature. Organize multiple connections with an intuitive tab system.
- icon: 🔐
title: User Authentication
details: Secure multi-user support with authentication system. Each user gets their own isolated environment and saved connections.
- icon: 💾
title: Save Hosts
details: Store and manage your connection details securely. Quickly connect to your favorite servers with just a click.
- icon: 🎨
title: SSHTerminal Themes
details: Customize your terminal experience with various color schemes and themes to reduce eye strain and match your preference.
- icon: 🐳
title: Easy Deployment
details: Quick setup using Docker or Docker Compose. Get up and running in minutes with minimal configuration required.
---

2458
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

14
docs/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"scripts": {
"docs:dev": "vitepress dev",
"docs:build": "vitepress build",
"docs:preview": "vitepress preview"
},
"dependencies": {
"node": "^22.15.0",
"yarn": "^1.22.22"
},
"devDependencies": {
"vitepress": "^1.6.3"
}
}

1
docs/public/CNAME Normal file
View File

@@ -0,0 +1 @@
termix.site

BIN
docs/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
docs/public/logo192.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
docs/public/logo512.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

1194
docs/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Termix</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5045
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

61
package.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "termix",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.525.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.60.0",
"react-resizable-panels": "^3.0.3",
"react-xtermjs": "^1.0.10",
"ssh2": "^1.16.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"ws": "^8.18.3",
"zod": "^4.0.5"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/node": "^24.0.13",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react-swc": "^3.10.2",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"tw-animate-css": "^1.3.5",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

46
src/App.tsx Normal file
View File

@@ -0,0 +1,46 @@
import React from "react"
import {Homepage} from "@/apps/Homepage/Homepage.tsx"
import {SSH} from "@/apps/SSH/SSH.tsx"
import {SSHTunnel} from "@/apps/SSH Tunnel/SSHTunnel.tsx";
import {ConfigEditor} from "@/apps/Config Editor/ConfigEditor.tsx";
import {Tools} from "@/apps/Tools/Tools.tsx";
function App() {
const [view, setView] = React.useState<string>("homepage")
const renderActiveView = () => {
switch (view) {
case "homepage":
return <Homepage
onSelectView={setView}
/>
case "ssh":
return <SSH
onSelectView={setView}
/>
case "ssh_tunnel":
return <SSHTunnel
onSelectView={setView}
/>
case "config_editor":
return <ConfigEditor
onSelectView={setView}
/>
case "tools":
return <Tools
onSelectView={setView}
/>
}
}
return (
<div className="flex">
<main>
{renderActiveView()}
</main>
</div>
)
}
export default App

View File

@@ -0,0 +1,18 @@
import {ConfigEditorSidebar} from "@/apps/Config Editor/ConfigEditorSidebar.tsx";
import React from "react";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
export function ConfigEditor({ onSelectView }: ConfigEditorProps): React.ReactElement {
return (
<div>
<ConfigEditorSidebar
onSelectView={onSelectView}
/>
Config Editor
</div>
)
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import {
CornerDownLeft
} from "lucide-react"
import {
Button
} from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
interface SidebarProps {
onSelectView: (view: string) => void;
}
export function ConfigEditorSidebar({ onSelectView }: SidebarProps): React.ReactElement {
return (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg text-center font-bold text-white">
Termix / Config Editor
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow">
<SidebarMenu>
{/* Sidebar Items */}
<SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")} variant="outline">
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1" />
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
)
}

View File

@@ -0,0 +1,16 @@
import {HomepageSidebar} from "@/apps/Homepage/HomepageSidebar.tsx";
import React from "react";
interface HomepageProps {
onSelectView: (view: string) => void;
}
export function Homepage({ onSelectView }: HomepageProps): React.ReactElement {
return (
<div className="flex">
<HomepageSidebar
onSelectView={onSelectView}
/>
</div>
)
}

View File

@@ -0,0 +1,82 @@
import React from 'react';
import {
Computer,
Server,
File,
Hammer
} from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
interface SidebarProps {
onSelectView: (view: string) => void;
}
export function HomepageSidebar({ onSelectView }: SidebarProps): React.ReactElement {
return (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg text-center font-bold text-white">
Termix
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent>
<SidebarMenu>
{/* Sidebar Items */}
<SidebarMenuItem key={"SSH"}>
<SidebarMenuButton asChild onClick={() => onSelectView("ssh")}>
<div>
<Computer/>
<span>{"SSH"}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"SSH Tunnel"}>
<SidebarMenuButton asChild onClick={() => onSelectView("ssh_tunnel")}>
<div>
<Server/>
<span>{"SSH Tunnel"}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Config Editor"}>
<SidebarMenuButton asChild onClick={() => onSelectView("config_editor")}>
<div>
<File/>
<span>{"Config Editor"}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Tools"}>
<SidebarMenuButton asChild onClick={() => onSelectView("tools")}>
<div>
<Hammer/>
<span>{"Tools"}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
)
}

View File

@@ -0,0 +1,18 @@
import React from "react";
import {SSHTunnelSidebar} from "@/apps/SSH Tunnel/SSHTunnelSidebar.tsx";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
return (
<div>
<SSHTunnelSidebar
onSelectView={onSelectView}
/>
SSH Tunnel
</div>
)
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import {
CornerDownLeft
} from "lucide-react"
import {
Button
} from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
interface SidebarProps {
onSelectView: (view: string) => void;
}
export function SSHTunnelSidebar({ onSelectView }: SidebarProps): React.ReactElement {
return (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg text-center font-bold text-white">
Termix / SSH Tunnel
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow">
<SidebarMenu>
{/* Sidebar Items */}
<SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")} variant="outline">
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1" />
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
)
}

474
src/apps/SSH/SSH.tsx Normal file
View File

@@ -0,0 +1,474 @@
import React, { useState, useRef } from "react";
import { SSHSidebar } from "@/apps/SSH/SSHSidebar.tsx";
import { SSHTerminal } from "./SSHTerminal.tsx";
import { SSHTopbar } from "@/apps/SSH/SSHTopbar.tsx";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
type Tab = {
id: number;
title: string;
hostConfig: any;
terminalRef: React.RefObject<any>;
};
function TerminalOverlay({ tabId, splitScreen }: { tabId: number, splitScreen: boolean }) {
React.useEffect(() => {
const el = document.getElementById(`terminal-container-${tabId}`);
if (el) {
el.style.opacity = '1';
el.style.zIndex = '10';
el.style.left = splitScreen ? '8px' : '0px';
el.style.width = splitScreen ? 'calc(100% - 8px)' : '100%';
}
return () => {
if (el) {
el.style.opacity = '0';
el.style.zIndex = '1';
}
};
}, [tabId, splitScreen]);
return <div style={{ width: '100%', height: '100%', position: 'relative' }} />;
}
export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
const [allTabs, setAllTabs] = useState<Tab[]>([]);
const [currentTab, setCurrentTab] = useState<number | null>(null);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const nextTabId = useRef(1);
const [splitKey, setSplitKey] = useState(0);
const setActiveTab = (tabId: number) => {
setCurrentTab(tabId);
};
// Helper to fit all visible terminals
const fitVisibleTerminals = () => {
allTabs.forEach((terminal) => {
const isVisible =
(allSplitScreenTab.length === 0 && terminal.id === currentTab) ||
(allSplitScreenTab.length > 0 && (terminal.id === currentTab || allSplitScreenTab.includes(terminal.id)));
if (isVisible && terminal.terminalRef && terminal.terminalRef.current && typeof terminal.terminalRef.current.fit === 'function') {
terminal.terminalRef.current.fit();
}
});
};
// Wrap setSplitScreenTab to fit before and after
const setSplitScreenTab = (tabId: number) => {
fitVisibleTerminals();
setAllSplitScreenTab((prev) => {
let next;
if (prev.includes(tabId)) {
next = prev.filter((id) => id !== tabId);
} else if (prev.length < 3) {
next = [...prev, tabId];
} else {
next = prev;
}
setTimeout(() => fitVisibleTerminals(), 0);
return next;
});
};
const setCloseTab = (tabId: number) => {
// Find the tab and call disconnect on its terminal
const tab = allTabs.find((t) => t.id === tabId);
if (tab && tab.terminalRef && tab.terminalRef.current && typeof tab.terminalRef.current.disconnect === "function") {
tab.terminalRef.current.disconnect();
}
setAllTabs((prev) => prev.filter((tab) => tab.id !== tabId));
setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId));
if (currentTab === tabId) {
const remainingTabs = allTabs.filter((tab) => tab.id !== tabId);
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : null);
}
};
// Render all terminals absolutely positioned, always mounted
const renderAllTerminals = () => (
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 1 }}>
{allTabs.map((tab) => (
<div
key={tab.id}
id={`terminal-container-${tab.id}`}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 1,
opacity: 0,
pointerEvents: 'auto',
transition: 'opacity 0.15s',
}}
data-terminal-id={tab.id}
>
<SSHTerminal
key={tab.id}
ref={tab.terminalRef}
hostConfig={tab.hostConfig}
isVisible={false}
title={tab.title}
showTitle={false}
splitScreen={false}
/>
</div>
))}
</div>
);
// Helper to show a terminal in a panel by toggling zIndex/opacity
const showTerminal = (tab: Tab, splitScreen: boolean) => (
<TerminalOverlay tabId={tab.id} splitScreen={splitScreen} />
);
const renderTerminals = () => {
if (allSplitScreenTab.length === 0) {
return (
<>
{allTabs.map((tab) => (
<div
key={tab.id}
style={{
height: '100%',
width: '100%',
position: 'absolute',
top: 0,
left: 0,
zIndex: tab.id === currentTab ? 10 : 1,
opacity: tab.id === currentTab ? 1 : 0,
transition: 'opacity 0.15s',
marginTop: 0,
}}
>
<TerminalOverlay tabId={tab.id} splitScreen={false} />
</div>
))}
</>
);
}
// Split screen logic
const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id));
const mainTab = allTabs.find((tab) => tab.id === currentTab);
const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== currentTab)].filter((t): t is Tab => !!t);
// 2 splits: horizontal
if (layoutTabs.length === 2) {
const [tab1, tab2] = layoutTabs;
return (
<ResizablePanelGroup key={splitKey} direction="horizontal" className="h-full w-full">
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
}}>{tab1.title}</div>
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
{showTerminal(tab1, true)}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel key={tab2.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
}}>{tab2.title}</div>
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
{showTerminal(tab2, true)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
// 3 splits: vertical group (top: horizontal with 2, bottom: single)
if (layoutTabs.length === 3) {
return (
<ResizablePanelGroup key={splitKey} direction="vertical" className="h-full w-full">
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
{/* Left/top panel */}
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>{layoutTabs[0].title}</div>
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
{showTerminal(layoutTabs[0], true)}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
{/* Right/top panel (no reset button here) */}
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span>{layoutTabs[1].title}</span>
</div>
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
{showTerminal(layoutTabs[1], true)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
display: 'flex',
alignItems: 'center',
}}>{layoutTabs[2].title}</div>
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
{showTerminal(layoutTabs[2], true)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
// 4 splits: 2x2 grid (vertical group with two horizontal groups)
if (layoutTabs.length === 4) {
return (
<ResizablePanelGroup key={splitKey} direction="vertical" className="h-full w-full">
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
}}>{layoutTabs[0].title}</div>
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
{showTerminal(layoutTabs[0], true)}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
}}>{layoutTabs[1].title}</div>
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
{showTerminal(layoutTabs[1], true)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
}}>{layoutTabs[2].title}</div>
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
{showTerminal(layoutTabs[2], true)}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full">
<div style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: '#09090b', margin: 0, padding: 0, position: 'relative'}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
}}>{layoutTabs[3].title}</div>
<div style={{flex: 1, position: 'relative', height: '100%', width: '100%', margin: 0, padding: 0}}>
{showTerminal(layoutTabs[3], true)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
);
}
return null;
};
const onAddHostSubmit = (data: any) => {
const id = nextTabId.current++;
const title = `${data.ip || "Host"}:${data.port || 22}`;
const terminalRef = React.createRef<any>();
const newTab: Tab = {
id,
title,
hostConfig: data,
terminalRef,
};
setAllTabs((prev) => [...prev, newTab]);
setCurrentTab(id);
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
};
const getLayoutStyle = () => {
if (allSplitScreenTab.length === 0) {
return "flex flex-col h-full w-full";
} else if (allSplitScreenTab.length === 1) {
return "grid grid-cols-2 h-full w-full";
} else if (allSplitScreenTab.length === 2) {
return "grid grid-cols-2 grid-rows-2 h-full w-full";
} else {
return "grid grid-cols-2 grid-rows-2 h-full w-full";
}
};
return (
<div style={{ display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden' }}>
{/* Sidebar: fixed width */}
<div style={{ width: 256, flexShrink: 0, height: '100vh', position: 'relative', zIndex: 2, margin: 0, padding: 0, border: 'none' }}>
<SSHSidebar
onSelectView={onSelectView}
onAddHostSubmit={onAddHostSubmit}
/>
</div>
{/* Main area: fills the rest */}
<div
className="terminal-container"
style={{
flex: 1,
height: '100vh',
position: 'relative',
overflow: 'hidden',
margin: 0,
padding: 0,
border: 'none',
}}
>
{/* Always render the topbar at the top */}
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', zIndex: 10 }}>
<SSHTopbar
allTabs={allTabs}
currentTab={currentTab ?? -1}
setActiveTab={setActiveTab}
allSplitScreenTab={allSplitScreenTab}
setSplitScreenTab={setSplitScreenTab}
setCloseTab={setCloseTab}
/>
</div>
{/* Split area below the topbar */}
<div style={{ height: 'calc(100% - 46px)', marginTop: 46, position: 'relative' }}>
{/* Absolutely render all terminals for persistence */}
{allSplitScreenTab.length > 0 && (
<div style={{ position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28 }}>
<button
style={{
background: '#18181b',
color: '#fff',
borderLeft: '1px solid #222224',
borderRight: '1px solid #222224',
borderTop: 'none',
borderBottom: '1px solid #222224',
borderRadius: 0,
padding: '2px 10px',
cursor: 'pointer',
fontSize: 13,
margin: 0,
height: 28,
display: 'flex',
alignItems: 'center',
}}
onClick={() => setSplitKey((k) => k + 1)}
>
Reset Split Sizes
</button>
</div>
)}
{renderAllTerminals()}
{renderTerminals()}
</div>
</div>
</div>
);
}

198
src/apps/SSH/SSHSidebar.tsx Normal file
View File

@@ -0,0 +1,198 @@
import React from 'react';
import {
CornerDownLeft,
Plus
} from "lucide-react"
import {
Button
} from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
import {
Sheet,
SheetClose,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger
} from "@/components/ui/sheet.tsx";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx";
import { useForm } from "react-hook-form"
import { Input } from "@/components/ui/input.tsx";
interface SidebarProps {
onSelectView: (view: string) => void;
onAddHostSubmit: (data: any) => void;
}
export function SSHSidebar({ onSelectView, onAddHostSubmit }: SidebarProps): React.ReactElement {
const addHostForm = useForm({
defaultValues: {
}
})
const onAddHostSubmitReset = (data: any) => {
addHostForm.reset();
onAddHostSubmit(data);
}
return (
<SidebarProvider>
<Sidebar className="h-full flex flex-col">
<SidebarContent className="flex flex-col flex-grow h-full">
<SidebarGroup className="flex flex-col flex-grow h-full">
<SidebarGroupLabel className="text-lg text-center font-bold text-white">
Termix / SSH
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow h-full">
<SidebarMenu className="flex flex-col flex-grow h-full">
<SidebarMenuItem key="Homepage">
<Button
className="w-full mt-2 mb-2 h-8"
onClick={() => onSelectView("homepage")}
variant="outline"
>
<CornerDownLeft />
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1" />
</SidebarMenuItem>
<SidebarMenuItem key="AddHost">
<Sheet>
<SheetTrigger asChild>
<Button
className="w-full mt-2 mb-2 h-8"
variant="outline"
>
<Plus />
Add Host
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col"
>
<SheetHeader className="pb-0.5">
<SheetTitle>Add Host</SheetTitle>
</SheetHeader>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto px-4">
<Form {...addHostForm}>
<form
id="add-host-form"
onSubmit={addHostForm.handleSubmit(onAddHostSubmitReset)}
className="space-y-3"
>
<FormField
control={addHostForm.control}
name="ip"
render={({ field }) => (
<FormItem>
<FormLabel>IP</FormLabel>
<FormControl>
<Input placeholder="127.0.0.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addHostForm.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input placeholder="22" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addHostForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="username123" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addHostForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="password123" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
<Separator className="p-0.25 mt-2" />
<SheetFooter className="px-4 pt-1 pb-4">
<SheetClose asChild>
<Button type="submit" form="add-host-form">
Add Host
</Button>
</SheetClose>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
</SidebarMenuItem>
<SidebarMenuItem key="Main" className="flex flex-col flex-grow">
<div className="flex w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] p-2 mb-1">
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,79 @@
import React from "react";
import {Button} from "@/components/ui/button.tsx";
import {X, SeparatorVertical} from "lucide-react"
interface TerminalTab {
id: number;
title: string;
}
interface SSHTabListProps {
allTabs: TerminalTab[];
currentTab: number;
setActiveTab: (tab: number) => void;
allSplitScreenTab: number[];
setSplitScreenTab: (tab: number) => void;
setCloseTab: (tab: number) => void;
}
export function SSHTabList({
allTabs,
currentTab,
setActiveTab,
allSplitScreenTab = [],
setSplitScreenTab,
setCloseTab,
}: SSHTabListProps): React.ReactElement {
const isSplitScreenActive = allSplitScreenTab.length > 0;
return (
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto">
{allTabs.map((terminal, index) => {
const isActive = terminal.id === currentTab;
const isSplit = allSplitScreenTab.includes(terminal.id);
const isSplitButtonDisabled =
(isActive && !isSplitScreenActive) ||
(allSplitScreenTab.length >= 3 && !isSplit);
return (
<div
key={terminal.id}
className={index < allTabs.length - 1 ? "mr-[0.5rem]" : ""}
>
<div className="inline-flex rounded-md shadow-sm" role="group">
{/* Set Active Tab Button */}
<Button
onClick={() => setActiveTab(terminal.id)}
disabled={isSplit}
variant="outline"
className={`rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
{terminal.title}
</Button>
{/* Split Screen Button */}
<Button
onClick={() => setSplitScreenTab(terminal.id)}
disabled={isSplitButtonDisabled || isActive}
variant="outline"
className="rounded-none p-0 !w-9 !h-9"
>
<SeparatorVertical className="!w-5 !h-5" strokeWidth={2.5} />
</Button>
{/* Close Tab Button */}
<Button
onClick={() => setCloseTab(terminal.id)}
disabled={(isSplitScreenActive && isActive) || isSplit}
variant="outline"
className="rounded-l-none p-0 !w-9 !h-9"
>
<X className="!w-5 !h-5" strokeWidth={2.5} />
</Button>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,178 @@
import React, { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
import { useXTerm } from 'react-xtermjs';
import { FitAddon } from '@xterm/addon-fit';
import { ClipboardAddon } from '@xterm/addon-clipboard';
interface SSHTerminalProps {
hostConfig: any;
isVisible: boolean;
title?: string;
showTitle?: boolean;
splitScreen?: boolean;
}
export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
{ hostConfig, isVisible, splitScreen = false },
ref
) {
console.log('Rendering SSHTerminal', { hostConfig, isVisible });
const { instance: terminal, ref: xtermRef } = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
const [visible, setVisible] = useState(false);
useImperativeHandle(ref, () => ({
disconnect: () => {
if (webSocketRef.current) {
webSocketRef.current.close();
}
},
fit: () => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
}
}), []);
useEffect(() => {
function handleWindowResize() {
fitAddonRef.current?.fit();
}
window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize);
}, []);
useEffect(() => {
if (!terminal || !xtermRef.current || !hostConfig) return;
const fitAddon = new FitAddon();
const clipboardAddon = new ClipboardAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(clipboardAddon);
terminal.open(xtermRef.current);
terminal.options = {
cursorBlink: true,
cursorStyle: 'bar',
scrollback: 5000,
fontSize: 15,
theme: {
background: '#09090b',
foreground: '#f7f7f7',
},
};
const onResize = () => {
if (!xtermRef.current) return;
const { width, height } = xtermRef.current.getBoundingClientRect();
if (width < 100 || height < 50) return;
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => {
fitAddonRef.current?.fit();
// Always send cols + 1
const cols = terminal.cols + 1;
const rows = terminal.rows;
webSocketRef.current?.send(JSON.stringify({
type: 'resize',
data: { cols, rows }
}));
}, 100);
};
const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(xtermRef.current);
setTimeout(() => {
fitAddon.fit();
setVisible(true);
// Always send cols + 1
const cols = terminal.cols + 1;
const rows = terminal.rows;
const ws = new WebSocket('ws://localhost:8082');
webSocketRef.current = ws;
ws.addEventListener('open', () => {
terminal.writeln('WebSocket opened');
ws.send(JSON.stringify({
type: 'connectToHost',
data: {
cols,
rows,
hostConfig: hostConfig
}
}));
terminal.onData((data) => {
ws.send(JSON.stringify({
type: 'input',
data
}));
});
});
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data);
console.log('WS message received:', msg); // Debug log
if (msg.type === 'data') {
terminal.write(msg.data);
} else if (msg.type === 'error') {
terminal.writeln(`\r\n[ERROR] ${msg.message}`);
} else if (msg.type === 'connected') {
terminal.writeln('[SSH connected. Waiting for prompt...]');
} else {
console.log('Unhandled message:', msg);
}
} catch (err) {
console.error('Failed to parse message', err);
}
});
ws.addEventListener('close', () => {
terminal.writeln('\r\n[Connection closed]');
});
ws.addEventListener('error', () => {
terminal.writeln('\r\n[Connection error]');
});
}, 300);
return () => {
resizeObserver.disconnect();
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
webSocketRef.current?.close();
};
}, [xtermRef, terminal, hostConfig]);
useEffect(() => {
if (isVisible && fitAddonRef.current) {
fitAddonRef.current.fit();
}
}, [isVisible]);
return (
<div
ref={xtermRef}
style={{
position: 'absolute',
top: splitScreen ? 0 : 48,
left: 0,
right: '-1ch',
bottom: 0,
marginLeft: 2,
opacity: visible && isVisible ? 1 : 0,
}}
/>
);
});

View File

@@ -0,0 +1,37 @@
import {SSHTabList} from "@/apps/SSH/SSHTabList.tsx";
import React from "react";
interface TerminalTab {
id: number;
title: string;
}
interface SSHTopbarProps {
allTabs: TerminalTab[];
currentTab: number;
setActiveTab: (tab: number) => void;
allSplitScreenTab: number[];
setSplitScreenTab: (tab: number) => void;
setCloseTab: (tab: number) => void;
}
export function SSHTopbar({ allTabs, currentTab, setActiveTab, allSplitScreenTab, setSplitScreenTab, setCloseTab }: SSHTopbarProps): React.ReactElement {
return (
<div className="flex h-11.5 z-100" style={{
position: 'absolute',
left: 0,
width: '100%',
backgroundColor: '#18181b',
borderBottom: '1px solid #222224',
}}>
<SSHTabList
allTabs={allTabs}
currentTab={currentTab}
setActiveTab={setActiveTab}
allSplitScreenTab={allSplitScreenTab}
setSplitScreenTab={setSplitScreenTab}
setCloseTab={setCloseTab}
/>
</div>
)
}

View File

@@ -0,0 +1,18 @@
import React from "react";
import {TemplateSidebar} from "@/apps/Template/TemplateSidebar.tsx";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
export function Template({ onSelectView }: ConfigEditorProps): React.ReactElement {
return (
<div>
<TemplateSidebar
onSelectView={onSelectView}
/>
Template
</div>
)
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import {
CornerDownLeft
} from "lucide-react"
import {
Button
} from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
interface SidebarProps {
onSelectView: (view: string) => void;
}
export function TemplateSidebar({ onSelectView }: SidebarProps): React.ReactElement {
return (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg text-center font-bold text-white">
Termix / Template
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow">
<SidebarMenu>
{/* Sidebar Items */}
<SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")} variant="outline">
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1" />
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
)
}

18
src/apps/Tools/Tools.tsx Normal file
View File

@@ -0,0 +1,18 @@
import React from "react";
import {ToolsSidebar} from "@/apps/Tools/ToolsSidebar.tsx";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
export function Tools({ onSelectView }: ConfigEditorProps): React.ReactElement {
return (
<div>
<ToolsSidebar
onSelectView={onSelectView}
/>
Template
</div>
)
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import {
CornerDownLeft
} from "lucide-react"
import {
Button
} from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
interface SidebarProps {
onSelectView: (view: string) => void;
}
export function ToolsSidebar({ onSelectView }: SidebarProps): React.ReactElement {
return (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg text-center font-bold text-white">
Termix / Tools
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow">
<SidebarMenu>
{/* Sidebar Items */}
<SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")} variant="outline">
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1" />
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
)
}

142
src/backend/ssh.cjs Normal file
View File

@@ -0,0 +1,142 @@
const WebSocket = require('ws');
const { Client } = require('ssh2');
const wss = new WebSocket.Server({ port: 8082 });
wss.on('connection', (ws) => {
let sshConn = null;
let sshStream = null;
ws.on('close', () => {
cleanupSSH();
});
ws.on('message', (msg) => {
let parsed;
try {
parsed = JSON.parse(msg);
} catch (e) {
console.error('Invalid JSON received:', msg);
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
return;
}
const { type, data } = parsed;
switch (type) {
case 'connectToHost':
handleConnectToHost(data);
break;
case 'resize':
handleResize(data);
break;
case 'disconnect':
cleanupSSH();
break;
case 'input':
if (sshStream) sshStream.write(data);
break;
default:
console.warn('Unknown message type:', type);
}
});
function handleConnectToHost({ cols, rows, hostConfig }) {
const { ip, port, username, password } = hostConfig;
sshConn = new Client();
sshConn.on('ready', () => {
sshConn.shell({
term: "xterm-256color",
cols,
rows,
modes: {
ECHO: 1,
ECHOCTL: 0,
ICANON: 1,
TTY_OP_OSWRAP: 1
}
}, (err, stream) => {
if (err) {
console.error('Shell error:', err);
ws.send(JSON.stringify({ type: 'error', message: 'Shell error: ' + err.message }));
return;
}
sshStream = stream;
stream.on('data', (chunk) => {
ws.send(JSON.stringify({ type: 'data', data: chunk.toString() }));
});
stream.on('close', () => {
cleanupSSH();
});
stream.on('error', (err) => {
console.error('SSH stream error:', err.message);
ws.send(JSON.stringify({ type: 'error', message: 'SSH stream error: ' + err.message }));
});
ws.send(JSON.stringify({ type: 'connected', message: 'SSH connected' }));
// stream.write('\n'); // Force prompt to appear (removed to avoid double prompt)
console.log('Sent connected message and newline to SSH stream');
});
});
sshConn.on('error', (err) => {
console.error('SSH connection error:', err.message);
ws.send(JSON.stringify({ type: 'error', message: 'SSH error: ' + err.message }));
cleanupSSH();
});
sshConn.on('close', () => {
cleanupSSH();
});
sshConn.connect({
host: ip,
port,
username,
password,
keepaliveInterval: 5000,
keepaliveCountMax: 10,
readyTimeout: 10000,
tcpKeepAlive: true,
});
}
function handleResize({ cols, rows }) {
if (sshStream && sshStream.setWindow) {
sshStream.setWindow(rows, cols, rows, cols);
ws.send(JSON.stringify({ type: 'resized', cols, rows }));
}
}
function cleanupSSH() {
if (sshStream) {
try {
sshStream.end();
} catch (e) {
console.error('Error closing stream:', e.message);
}
sshStream = null;
}
if (sshConn) {
try {
sshConn.end();
} catch (e) {
console.error('Error closing connection:', e.message);
}
sshConn = null;
}
}
});
console.log('WebSocket server running on ws://localhost:8082');

View File

@@ -0,0 +1,73 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

165
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,165 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,54 @@
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

137
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,137 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={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
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,725 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-full w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
// eslint-disable-next-line react-refresh/only-export-components
useSidebar,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

19
src/hooks/use-mobile.ts Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

132
src/index.css Normal file
View File

@@ -0,0 +1,132 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #09090b;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--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.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
@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);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--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.552 0.016 285.938);
--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.21 0.006 285.885);
--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.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

13
src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import {ThemeProvider} from "@/components/theme-provider"
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<App/>
</ThemeProvider>
</StrictMode>,
)

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

35
tsconfig.app.json Normal file
View File

@@ -0,0 +1,35 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* shadcn */
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["src"]
}

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

25
tsconfig.node.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

14
vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})