Compare commits
9 Commits
feat/repla
...
53a2f1ff0c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53a2f1ff0c | ||
|
|
dc15212ecf | ||
|
|
99e847175e | ||
|
|
b2c7fa6e83 | ||
|
|
9e7f18787b | ||
| 47507dd65a | |||
|
|
e6f94c3e71 | ||
|
|
66af870aa9 | ||
|
|
8047bce755 |
10
Dockerfile
10
Dockerfile
@@ -2,16 +2,20 @@ FROM oven/bun:latest AS base
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y git
|
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install dependencies
|
# Install root project dependencies
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Install web project dependencies
|
||||||
|
COPY src/web/package.json src/web/bun.lock ./src/web/
|
||||||
|
RUN cd src/web && bun install --frozen-lockfile
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose port
|
# Expose ports (3000 for web dashboard)
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Default command
|
# Default command
|
||||||
|
|||||||
@@ -6,11 +6,20 @@ services:
|
|||||||
- POSTGRES_USER=${DB_USER}
|
- POSTGRES_USER=${DB_USER}
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
- POSTGRES_DB=${DB_NAME}
|
- POSTGRES_DB=${DB_NAME}
|
||||||
ports:
|
# Uncomment to access DB from host (for debugging/drizzle-kit studio)
|
||||||
- "127.0.0.1:${DB_PORT}:5432"
|
# ports:
|
||||||
|
# - "127.0.0.1:${DB_PORT}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/db/data:/var/lib/postgresql/data
|
- ./src/db/data:/var/lib/postgresql/data
|
||||||
- ./src/db/log:/var/log/postgresql
|
- ./src/db/log:/var/log/postgresql
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}" ]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
app:
|
app:
|
||||||
container_name: aurora_app
|
container_name: aurora_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -20,22 +29,34 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
- /app/src/web/node_modules
|
||||||
environment:
|
environment:
|
||||||
|
- HOST=0.0.0.0
|
||||||
- DB_USER=${DB_USER}
|
- DB_USER=${DB_USER}
|
||||||
- DB_PASSWORD=${DB_PASSWORD}
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
- DB_NAME=${DB_NAME}
|
- DB_NAME=${DB_NAME}
|
||||||
- DB_PORT=${DB_PORT}
|
- DB_PORT=5432
|
||||||
- DB_HOST=db
|
- DB_HOST=db
|
||||||
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
|
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
|
||||||
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
||||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- web
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "bun", "-e", "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" ]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
command: bun run dev
|
command: bun run dev
|
||||||
|
|
||||||
studio:
|
studio:
|
||||||
@@ -50,13 +71,24 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
- /app/src/web/node_modules
|
||||||
environment:
|
environment:
|
||||||
- DB_USER=${DB_USER}
|
- DB_USER=${DB_USER}
|
||||||
- DB_PASSWORD=${DB_PASSWORD}
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
- DB_NAME=${DB_NAME}
|
- DB_NAME=${DB_NAME}
|
||||||
- DB_PORT=${DB_PORT}
|
- DB_PORT=5432
|
||||||
- DB_HOST=db
|
- DB_HOST=db
|
||||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
command: bun run db:studio
|
command: bun run db:studio
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
internal: true # No external access
|
||||||
|
web:
|
||||||
|
driver: bridge # Can be accessed from host
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
"dev": "bun --watch src/index.ts",
|
"dev": "bun --watch src/index.ts",
|
||||||
"db:studio": "drizzle-kit studio --host 0.0.0.0",
|
"db:studio": "drizzle-kit studio --host 0.0.0.0",
|
||||||
"studio:remote": "bash scripts/remote-studio.sh",
|
"studio:remote": "bash scripts/remote-studio.sh",
|
||||||
|
"dashboard:remote": "bash scripts/remote-dashboard.sh",
|
||||||
|
"remote": "bash scripts/remote.sh",
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
43
scripts/remote-dashboard.sh
Executable file
43
scripts/remote-dashboard.sh
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
if [ -f .env ]; then
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ]; then
|
||||||
|
echo "Error: VPS_HOST and VPS_USER must be set in .env"
|
||||||
|
echo "Please add them to your .env file:"
|
||||||
|
echo "VPS_USER=your-username"
|
||||||
|
echo "VPS_HOST=your-ip-address"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DASHBOARD_PORT=${DASHBOARD_PORT:-3000}
|
||||||
|
|
||||||
|
echo "🌐 Establishing secure tunnel to Aurora Dashboard..."
|
||||||
|
echo "📊 Dashboard will be accessible at: http://localhost:$DASHBOARD_PORT"
|
||||||
|
echo "Press Ctrl+C to stop the connection."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Function to open browser (cross-platform)
|
||||||
|
open_browser() {
|
||||||
|
sleep 2
|
||||||
|
if command -v open &> /dev/null; then
|
||||||
|
open "http://localhost:$DASHBOARD_PORT"
|
||||||
|
elif command -v xdg-open &> /dev/null; then
|
||||||
|
xdg-open "http://localhost:$DASHBOARD_PORT"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if autossh is available
|
||||||
|
if command -v autossh &> /dev/null; then
|
||||||
|
SSH_CMD="autossh -M 0 -o ServerAliveInterval=30 -o ServerAliveCountMax=3"
|
||||||
|
else
|
||||||
|
SSH_CMD="ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=3"
|
||||||
|
fi
|
||||||
|
|
||||||
|
open_browser &
|
||||||
|
$SSH_CMD -N -L $DASHBOARD_PORT:127.0.0.1:$DASHBOARD_PORT $VPS_USER@$VPS_HOST
|
||||||
61
scripts/remote.sh
Executable file
61
scripts/remote.sh
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
if [ -f .env ]; then
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ]; then
|
||||||
|
echo "Error: VPS_HOST and VPS_USER must be set in .env"
|
||||||
|
echo "Please add them to your .env file:"
|
||||||
|
echo "VPS_USER=your-username"
|
||||||
|
echo "VPS_HOST=your-ip-address"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DASHBOARD_PORT=${DASHBOARD_PORT:-3000}
|
||||||
|
STUDIO_PORT=${STUDIO_PORT:-4983}
|
||||||
|
|
||||||
|
echo "🚀 Establishing secure tunnels to Aurora services..."
|
||||||
|
echo ""
|
||||||
|
echo "📊 Dashboard: http://localhost:$DASHBOARD_PORT"
|
||||||
|
echo "🔮 Studio: http://localhost:$STUDIO_PORT (https://local.drizzle.studio)"
|
||||||
|
echo ""
|
||||||
|
echo "Press Ctrl+C to stop all connections."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Function to open browser (cross-platform)
|
||||||
|
open_browser() {
|
||||||
|
sleep 2 # Wait for tunnel to establish
|
||||||
|
if command -v open &> /dev/null; then
|
||||||
|
# macOS
|
||||||
|
open "http://localhost:$DASHBOARD_PORT"
|
||||||
|
elif command -v xdg-open &> /dev/null; then
|
||||||
|
# Linux
|
||||||
|
xdg-open "http://localhost:$DASHBOARD_PORT"
|
||||||
|
elif command -v start &> /dev/null; then
|
||||||
|
# Windows (Git Bash)
|
||||||
|
start "http://localhost:$DASHBOARD_PORT"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if autossh is available for auto-reconnection
|
||||||
|
if command -v autossh &> /dev/null; then
|
||||||
|
echo "✅ Using autossh for automatic reconnection"
|
||||||
|
SSH_CMD="autossh -M 0 -o ServerAliveInterval=30 -o ServerAliveCountMax=3"
|
||||||
|
else
|
||||||
|
echo "💡 Tip: Install autossh for automatic reconnection (brew install autossh)"
|
||||||
|
SSH_CMD="ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=3"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Open browser in background
|
||||||
|
open_browser &
|
||||||
|
|
||||||
|
# Start both tunnels
|
||||||
|
# -N means "Do not execute a remote command". -L is for local port forwarding.
|
||||||
|
$SSH_CMD -N \
|
||||||
|
-L $DASHBOARD_PORT:127.0.0.1:$DASHBOARD_PORT \
|
||||||
|
-L $STUDIO_PORT:127.0.0.1:$STUDIO_PORT \
|
||||||
|
$VPS_USER@$VPS_HOST
|
||||||
@@ -9,91 +9,168 @@ import {
|
|||||||
getUpdatingEmbed,
|
getUpdatingEmbed,
|
||||||
getCancelledEmbed,
|
getCancelledEmbed,
|
||||||
getTimeoutEmbed,
|
getTimeoutEmbed,
|
||||||
getErrorEmbed
|
getErrorEmbed,
|
||||||
|
getRollbackSuccessEmbed,
|
||||||
|
getRollbackFailedEmbed
|
||||||
} from "@/modules/admin/update.view";
|
} from "@/modules/admin/update.view";
|
||||||
|
|
||||||
export const update = createCommand({
|
export const update = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName("update")
|
.setName("update")
|
||||||
.setDescription("Check for updates and restart the bot")
|
.setDescription("Check for updates and restart the bot")
|
||||||
.addBooleanOption(option =>
|
.addSubcommand(sub =>
|
||||||
option.setName("force")
|
sub.setName("check")
|
||||||
.setDescription("Force update even if checks fail (not recommended)")
|
.setDescription("Check for and apply available updates")
|
||||||
.setRequired(false)
|
.addBooleanOption(option =>
|
||||||
|
option.setName("force")
|
||||||
|
.setDescription("Force update even if no changes detected")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("rollback")
|
||||||
|
.setDescription("Rollback to the previous version")
|
||||||
)
|
)
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
const subcommand = interaction.options.getSubcommand();
|
||||||
const force = interaction.options.getBoolean("force") || false;
|
|
||||||
|
|
||||||
try {
|
if (subcommand === "rollback") {
|
||||||
await interaction.editReply({ embeds: [getCheckingEmbed()] });
|
await handleRollback(interaction);
|
||||||
|
} else {
|
||||||
const { hasUpdates, log, branch } = await UpdateService.checkForUpdates();
|
await handleUpdate(interaction);
|
||||||
|
|
||||||
if (!hasUpdates && !force) {
|
|
||||||
await interaction.editReply({ embeds: [getNoUpdatesEmbed()] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { embeds, components } = getUpdatesAvailableMessage(branch, log, force);
|
|
||||||
const response = await interaction.editReply({ embeds, components });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const confirmation = await response.awaitMessageComponent({
|
|
||||||
filter: (i) => i.user.id === interaction.user.id,
|
|
||||||
componentType: ComponentType.Button,
|
|
||||||
time: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
if (confirmation.customId === "confirm_update") {
|
|
||||||
await confirmation.update({
|
|
||||||
embeds: [getPreparingEmbed()],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1. Check what the update requires
|
|
||||||
const { needsInstall, needsMigrations } = await UpdateService.checkUpdateRequirements(branch);
|
|
||||||
|
|
||||||
// 2. Prepare context BEFORE update
|
|
||||||
await UpdateService.prepareRestartContext({
|
|
||||||
channelId: interaction.channelId,
|
|
||||||
userId: interaction.user.id,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
runMigrations: needsMigrations,
|
|
||||||
installDependencies: needsInstall
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Update UI to "Restarting" state
|
|
||||||
await interaction.editReply({ embeds: [getUpdatingEmbed(needsInstall)] });
|
|
||||||
|
|
||||||
// 4. Perform Update (Danger Zone)
|
|
||||||
await UpdateService.performUpdate(branch);
|
|
||||||
|
|
||||||
// 5. Trigger Restart (if we are still alive)
|
|
||||||
await UpdateService.triggerRestart();
|
|
||||||
|
|
||||||
} else {
|
|
||||||
await confirmation.update({
|
|
||||||
embeds: [getCancelledEmbed()],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message.includes("time")) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getTimeoutEmbed()],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Update failed:", error);
|
|
||||||
await interaction.editReply({ embeds: [getErrorEmbed(error)] });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function handleUpdate(interaction: any) {
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
const force = interaction.options.getBoolean("force") || false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Check for updates
|
||||||
|
await interaction.editReply({ embeds: [getCheckingEmbed()] });
|
||||||
|
const updateInfo = await UpdateService.checkForUpdates();
|
||||||
|
|
||||||
|
if (!updateInfo.hasUpdates && !force) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Analyze requirements
|
||||||
|
const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch);
|
||||||
|
const categories = UpdateService.categorizeChanges(requirements.changedFiles);
|
||||||
|
|
||||||
|
// 3. Show confirmation with details
|
||||||
|
const { embeds, components } = getUpdatesAvailableMessage(
|
||||||
|
updateInfo,
|
||||||
|
requirements,
|
||||||
|
categories,
|
||||||
|
force
|
||||||
|
);
|
||||||
|
const response = await interaction.editReply({ embeds, components });
|
||||||
|
|
||||||
|
// 4. Wait for confirmation
|
||||||
|
try {
|
||||||
|
const confirmation = await response.awaitMessageComponent({
|
||||||
|
filter: (i: any) => i.user.id === interaction.user.id,
|
||||||
|
componentType: ComponentType.Button,
|
||||||
|
time: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmation.customId === "confirm_update") {
|
||||||
|
await confirmation.update({
|
||||||
|
embeds: [getPreparingEmbed()],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Save rollback point
|
||||||
|
const previousCommit = await UpdateService.saveRollbackPoint();
|
||||||
|
|
||||||
|
// 6. Prepare restart context
|
||||||
|
await UpdateService.prepareRestartContext({
|
||||||
|
channelId: interaction.channelId,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
runMigrations: requirements.needsMigrations,
|
||||||
|
installDependencies: requirements.needsRootInstall || requirements.needsWebInstall,
|
||||||
|
previousCommit: previousCommit.substring(0, 7),
|
||||||
|
newCommit: updateInfo.latestCommit
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Show updating status
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getUpdatingEmbed(requirements)]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. Perform update
|
||||||
|
await UpdateService.performUpdate(updateInfo.branch);
|
||||||
|
|
||||||
|
// 9. Trigger restart
|
||||||
|
await UpdateService.triggerRestart();
|
||||||
|
|
||||||
|
} else {
|
||||||
|
await confirmation.update({
|
||||||
|
embeds: [getCancelledEmbed()],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message.includes("time")) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getTimeoutEmbed()],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update failed:", error);
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getErrorEmbed(error)],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRollback(interaction: any) {
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasRollback = await UpdateService.hasRollbackPoint();
|
||||||
|
|
||||||
|
if (!hasRollback) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getRollbackFailedEmbed("No rollback point available. Rollback is only possible after a recent update.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await UpdateService.rollback();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getRollbackSuccessEmbed(result.message.split(" ").pop() || "unknown")]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restart after rollback
|
||||||
|
setTimeout(() => UpdateService.triggerRestart(), 1000);
|
||||||
|
} else {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getRollbackFailedEmbed(result.message)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Rollback failed:", error);
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getErrorEmbed(error)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
30
src/index.ts
30
src/index.ts
@@ -1,14 +1,27 @@
|
|||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { env } from "@lib/env";
|
import { env } from "@lib/env";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { WebServer } from "@/web/server";
|
import { startWebServerFromRoot } from "./web/src/server";
|
||||||
|
|
||||||
// Load commands & events
|
// Load commands & events
|
||||||
await AuroraClient.loadCommands();
|
await AuroraClient.loadCommands();
|
||||||
await AuroraClient.loadEvents();
|
await AuroraClient.loadEvents();
|
||||||
await AuroraClient.deployCommands();
|
await AuroraClient.deployCommands();
|
||||||
|
|
||||||
WebServer.start();
|
console.log("🌐 Starting web server...");
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
const webProjectPath = join(import.meta.dir, "web");
|
||||||
|
const webPort = Number(process.env.WEB_PORT) || 3000;
|
||||||
|
const webHost = process.env.HOST || "0.0.0.0";
|
||||||
|
|
||||||
|
// Start web server in the same process
|
||||||
|
const webServer = await startWebServerFromRoot(webProjectPath, {
|
||||||
|
port: webPort,
|
||||||
|
hostname: webHost,
|
||||||
|
});
|
||||||
|
|
||||||
// login with the token from .env
|
// login with the token from .env
|
||||||
if (!env.DISCORD_BOT_TOKEN) {
|
if (!env.DISCORD_BOT_TOKEN) {
|
||||||
@@ -17,9 +30,18 @@ if (!env.DISCORD_BOT_TOKEN) {
|
|||||||
AuroraClient.login(env.DISCORD_BOT_TOKEN);
|
AuroraClient.login(env.DISCORD_BOT_TOKEN);
|
||||||
|
|
||||||
// Handle graceful shutdown
|
// Handle graceful shutdown
|
||||||
const shutdownHandler = () => {
|
const shutdownHandler = async () => {
|
||||||
WebServer.stop();
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
console.log("🛑 Shutdown signal received. Stopping services...");
|
||||||
|
|
||||||
|
// Stop web server
|
||||||
|
await webServer.stop();
|
||||||
|
|
||||||
|
// Stop bot
|
||||||
AuroraClient.shutdown();
|
AuroraClient.shutdown();
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
process.on("SIGINT", shutdownHandler);
|
process.on("SIGINT", shutdownHandler);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { Command } from "@lib/types";
|
|||||||
import { env } from "@lib/env";
|
import { env } from "@lib/env";
|
||||||
import { CommandLoader } from "@lib/loaders/CommandLoader";
|
import { CommandLoader } from "@lib/loaders/CommandLoader";
|
||||||
import { EventLoader } from "@lib/loaders/EventLoader";
|
import { EventLoader } from "@lib/loaders/EventLoader";
|
||||||
import { logger } from "@lib/logger";
|
|
||||||
|
|
||||||
export class Client extends DiscordClient {
|
export class Client extends DiscordClient {
|
||||||
|
|
||||||
@@ -23,25 +22,25 @@ export class Client extends DiscordClient {
|
|||||||
async loadCommands(reload: boolean = false) {
|
async loadCommands(reload: boolean = false) {
|
||||||
if (reload) {
|
if (reload) {
|
||||||
this.commands.clear();
|
this.commands.clear();
|
||||||
logger.info("♻️ Reloading commands...");
|
console.log("♻️ Reloading commands...");
|
||||||
}
|
}
|
||||||
|
|
||||||
const commandsPath = join(import.meta.dir, '../commands');
|
const commandsPath = join(import.meta.dir, '../commands');
|
||||||
const result = await this.commandLoader.loadFromDirectory(commandsPath, reload);
|
const result = await this.commandLoader.loadFromDirectory(commandsPath, reload);
|
||||||
|
|
||||||
logger.info(`📦 Command loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
console.log(`📦 Command loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadEvents(reload: boolean = false) {
|
async loadEvents(reload: boolean = false) {
|
||||||
if (reload) {
|
if (reload) {
|
||||||
this.removeAllListeners();
|
this.removeAllListeners();
|
||||||
logger.info("♻️ Reloading events...");
|
console.log("♻️ Reloading events...");
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsPath = join(import.meta.dir, '../events');
|
const eventsPath = join(import.meta.dir, '../events');
|
||||||
const result = await this.eventLoader.loadFromDirectory(eventsPath, reload);
|
const result = await this.eventLoader.loadFromDirectory(eventsPath, reload);
|
||||||
|
|
||||||
logger.info(`📦 Event loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
console.log(`📦 Event loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -50,7 +49,7 @@ export class Client extends DiscordClient {
|
|||||||
// We use env.DISCORD_BOT_TOKEN directly so this can run without client.login()
|
// We use env.DISCORD_BOT_TOKEN directly so this can run without client.login()
|
||||||
const token = env.DISCORD_BOT_TOKEN;
|
const token = env.DISCORD_BOT_TOKEN;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
logger.error("DISCORD_BOT_TOKEN is not set.");
|
console.error("DISCORD_BOT_TOKEN is not set.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,16 +59,16 @@ export class Client extends DiscordClient {
|
|||||||
const clientId = env.DISCORD_CLIENT_ID;
|
const clientId = env.DISCORD_CLIENT_ID;
|
||||||
|
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
logger.error("DISCORD_CLIENT_ID is not set.");
|
console.error("DISCORD_CLIENT_ID is not set.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info(`Started refreshing ${commandsData.length} application (/) commands.`);
|
console.log(`Started refreshing ${commandsData.length} application (/) commands.`);
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
if (guildId) {
|
if (guildId) {
|
||||||
logger.info(`Registering commands to guild: ${guildId}`);
|
console.log(`Registering commands to guild: ${guildId}`);
|
||||||
data = await rest.put(
|
data = await rest.put(
|
||||||
Routes.applicationGuildCommands(clientId, guildId),
|
Routes.applicationGuildCommands(clientId, guildId),
|
||||||
{ body: commandsData },
|
{ body: commandsData },
|
||||||
@@ -77,20 +76,20 @@ export class Client extends DiscordClient {
|
|||||||
// Clear global commands to avoid duplicates
|
// Clear global commands to avoid duplicates
|
||||||
await rest.put(Routes.applicationCommands(clientId), { body: [] });
|
await rest.put(Routes.applicationCommands(clientId), { body: [] });
|
||||||
} else {
|
} else {
|
||||||
logger.info('Registering commands globally');
|
console.log('Registering commands globally');
|
||||||
data = await rest.put(
|
data = await rest.put(
|
||||||
Routes.applicationCommands(clientId),
|
Routes.applicationCommands(clientId),
|
||||||
{ body: commandsData },
|
{ body: commandsData },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`Successfully reloaded ${(data as any).length} application (/) commands.`);
|
console.log(`Successfully reloaded ${(data as any).length} application (/) commands.`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.code === 50001) {
|
if (error.code === 50001) {
|
||||||
logger.warn("Missing Access: The bot is not in the guild or lacks 'applications.commands' scope.");
|
console.warn("Missing Access: The bot is not in the guild or lacks 'applications.commands' scope.");
|
||||||
logger.warn(" If you are testing locally, make sure you invited the bot with scope 'bot applications.commands'.");
|
console.warn(" If you are testing locally, make sure you invited the bot with scope 'bot applications.commands'.");
|
||||||
} else {
|
} else {
|
||||||
logger.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,22 +98,22 @@ export class Client extends DiscordClient {
|
|||||||
const { setShuttingDown, waitForTransactions } = await import("./shutdown");
|
const { setShuttingDown, waitForTransactions } = await import("./shutdown");
|
||||||
const { closeDatabase } = await import("./DrizzleClient");
|
const { closeDatabase } = await import("./DrizzleClient");
|
||||||
|
|
||||||
logger.info("🛑 Shutdown signal received. Starting graceful shutdown...");
|
console.log("🛑 Shutdown signal received. Starting graceful shutdown...");
|
||||||
setShuttingDown(true);
|
setShuttingDown(true);
|
||||||
|
|
||||||
// Wait for transactions to complete
|
// Wait for transactions to complete
|
||||||
logger.info("⏳ Waiting for active transactions to complete...");
|
console.log("⏳ Waiting for active transactions to complete...");
|
||||||
await waitForTransactions(10000);
|
await waitForTransactions(10000);
|
||||||
|
|
||||||
// Destroy Discord client
|
// Destroy Discord client
|
||||||
logger.info("🔌 Disconnecting from Discord...");
|
console.log("🔌 Disconnecting from Discord...");
|
||||||
this.destroy();
|
this.destroy();
|
||||||
|
|
||||||
// Close database
|
// Close database
|
||||||
logger.info("🗄️ Closing database connection...");
|
console.log("🗄️ Closing database connection...");
|
||||||
await closeDatabase();
|
await closeDatabase();
|
||||||
|
|
||||||
logger.success("👋 Graceful shutdown complete. Exiting.");
|
console.log("👋 Graceful shutdown complete. Exiting.");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const envSchema = z.object({
|
|||||||
DISCORD_GUILD_ID: z.string().optional(),
|
DISCORD_GUILD_ID: z.string().optional(),
|
||||||
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
||||||
PORT: z.coerce.number().default(3000),
|
PORT: z.coerce.number().default(3000),
|
||||||
|
HOST: z.string().default("127.0.0.1"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsedEnv = envSchema.safeParse(process.env);
|
const parsedEnv = envSchema.safeParse(process.env);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AutocompleteInteraction } from "discord.js";
|
import { AutocompleteInteraction } from "discord.js";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { logger } from "@lib/logger";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles autocomplete interactions for slash commands
|
* Handles autocomplete interactions for slash commands
|
||||||
@@ -16,7 +16,7 @@ export class AutocompleteHandler {
|
|||||||
try {
|
try {
|
||||||
await command.autocomplete(interaction);
|
await command.autocomplete(interaction);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
|||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@/modules/user/user.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { logger } from "@lib/logger";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles slash command execution
|
* Handles slash command execution
|
||||||
@@ -13,7 +13,7 @@ export class CommandHandler {
|
|||||||
const command = AuroraClient.commands.get(interaction.commandName);
|
const command = AuroraClient.commands.get(interaction.commandName);
|
||||||
|
|
||||||
if (!command) {
|
if (!command) {
|
||||||
logger.error(`No command matching ${interaction.commandName} was found.`);
|
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,14 +21,14 @@ export class CommandHandler {
|
|||||||
try {
|
try {
|
||||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to ensure user exists:", error);
|
console.error("Failed to ensure user exists:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await command.execute(interaction);
|
await command.execute(interaction);
|
||||||
AuroraClient.lastCommandTimestamp = Date.now();
|
AuroraClient.lastCommandTimestamp = Date.now();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(String(error));
|
console.error(String(error));
|
||||||
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
||||||
|
|
||||||
if (interaction.replied || interaction.deferred) {
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
||||||
import { logger } from "@lib/logger";
|
|
||||||
import { UserError } from "@lib/errors";
|
import { UserError } from "@lib/errors";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export class ComponentInteractionHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error(`Handler method ${route.method} not found in module`);
|
console.error(`Handler method ${route.method} not found in module`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ export class ComponentInteractionHandler {
|
|||||||
|
|
||||||
// Log system errors (non-user errors) for debugging
|
// Log system errors (non-user errors) for debugging
|
||||||
if (!isUserError) {
|
if (!isUserError) {
|
||||||
logger.error(`Error in ${handlerName}:`, error);
|
console.error(`Error in ${handlerName}:`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorEmbed = createErrorEmbed(errorMessage);
|
const errorEmbed = createErrorEmbed(errorMessage);
|
||||||
@@ -72,7 +72,7 @@ export class ComponentInteractionHandler {
|
|||||||
}
|
}
|
||||||
} catch (replyError) {
|
} catch (replyError) {
|
||||||
// If we can't send a reply, log it
|
// If we can't send a reply, log it
|
||||||
logger.error(`Failed to send error response in ${handlerName}:`, replyError);
|
console.error(`Failed to send error response in ${handlerName}:`, replyError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Command } from "@lib/types";
|
|||||||
import { config } from "@lib/config";
|
import { config } from "@lib/config";
|
||||||
import type { LoadResult, LoadError } from "./types";
|
import type { LoadResult, LoadError } from "./types";
|
||||||
import type { Client } from "../BotClient";
|
import type { Client } from "../BotClient";
|
||||||
import { logger } from "@lib/logger";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles loading commands from the file system
|
* Handles loading commands from the file system
|
||||||
@@ -45,7 +45,7 @@ export class CommandLoader {
|
|||||||
await this.loadCommandFile(filePath, reload, result);
|
await this.loadCommandFile(filePath, reload, result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error reading directory ${dir}:`, error);
|
console.error(`Error reading directory ${dir}:`, error);
|
||||||
result.errors.push({ file: dir, error });
|
result.errors.push({ file: dir, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ export class CommandLoader {
|
|||||||
const commands = Object.values(commandModule);
|
const commands = Object.values(commandModule);
|
||||||
|
|
||||||
if (commands.length === 0) {
|
if (commands.length === 0) {
|
||||||
logger.warn(`No commands found in ${filePath}`);
|
console.warn(`No commands found in ${filePath}`);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -74,21 +74,21 @@ export class CommandLoader {
|
|||||||
const isEnabled = config.commands[command.data.name] !== false;
|
const isEnabled = config.commands[command.data.name] !== false;
|
||||||
|
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
logger.info(`🚫 Skipping disabled command: ${command.data.name}`);
|
console.log(`🚫 Skipping disabled command: ${command.data.name}`);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.client.commands.set(command.data.name, command);
|
this.client.commands.set(command.data.name, command);
|
||||||
logger.success(`Loaded command: ${command.data.name}`);
|
console.log(`Loaded command: ${command.data.name}`);
|
||||||
result.loaded++;
|
result.loaded++;
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`Skipping invalid command in ${filePath}`);
|
console.warn(`Skipping invalid command in ${filePath}`);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to load command from ${filePath}:`, error);
|
console.error(`Failed to load command from ${filePath}:`, error);
|
||||||
result.errors.push({ file: filePath, error });
|
result.errors.push({ file: filePath, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|||||||
import type { Event } from "@lib/types";
|
import type { Event } from "@lib/types";
|
||||||
import type { LoadResult } from "./types";
|
import type { LoadResult } from "./types";
|
||||||
import type { Client } from "../BotClient";
|
import type { Client } from "../BotClient";
|
||||||
import { logger } from "@lib/logger";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles loading events from the file system
|
* Handles loading events from the file system
|
||||||
@@ -44,7 +44,7 @@ export class EventLoader {
|
|||||||
await this.loadEventFile(filePath, reload, result);
|
await this.loadEventFile(filePath, reload, result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error reading directory ${dir}:`, error);
|
console.error(`Error reading directory ${dir}:`, error);
|
||||||
result.errors.push({ file: dir, error });
|
result.errors.push({ file: dir, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,14 +64,14 @@ export class EventLoader {
|
|||||||
} else {
|
} else {
|
||||||
this.client.on(event.name, (...args) => event.execute(...args));
|
this.client.on(event.name, (...args) => event.execute(...args));
|
||||||
}
|
}
|
||||||
logger.success(`Loaded event: ${event.name}`);
|
console.log(`Loaded event: ${event.name}`);
|
||||||
result.loaded++;
|
result.loaded++;
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`Skipping invalid event in ${filePath}`);
|
console.warn(`Skipping invalid event in ${filePath}`);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to load event from ${filePath}:`, error);
|
console.error(`Failed to load event from ${filePath}:`, error);
|
||||||
result.errors.push({ file: filePath, error });
|
result.errors.push({ file: filePath, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach } from "bun:test";
|
|
||||||
import { logger, getRecentLogs } from "./logger";
|
|
||||||
|
|
||||||
describe("Logger Buffer", () => {
|
|
||||||
// Note: Since the buffer is a module-level variable, it persists across tests.
|
|
||||||
// In a real scenario we might want a reset function, but for now we'll just check relative additions.
|
|
||||||
|
|
||||||
it("should add logs to the buffer", () => {
|
|
||||||
const initialLength = getRecentLogs().length;
|
|
||||||
logger.info("Test Info Log");
|
|
||||||
const newLogs = getRecentLogs();
|
|
||||||
|
|
||||||
expect(newLogs.length).toBe(initialLength + 1);
|
|
||||||
expect(newLogs[0]?.message).toBe("Test Info Log");
|
|
||||||
expect(newLogs[0]?.type).toBe("info");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should cap the buffer size at 50", () => {
|
|
||||||
// Fill the buffer
|
|
||||||
for (let i = 0; i < 60; i++) {
|
|
||||||
logger.debug(`Log overflow test ${i}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const logs = getRecentLogs();
|
|
||||||
expect(logs.length).toBeLessThanOrEqual(50);
|
|
||||||
expect(logs[0]?.message).toBe("Log overflow test 59");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle different log levels", () => {
|
|
||||||
logger.error("Critical Error");
|
|
||||||
logger.success("Operation Successful");
|
|
||||||
|
|
||||||
const logs = getRecentLogs();
|
|
||||||
expect(logs[0]?.type).toBe("success");
|
|
||||||
expect(logs[1]?.type).toBe("error");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { WebServer } from "@/web/server";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Centralized logging utility with consistent formatting
|
|
||||||
*/
|
|
||||||
|
|
||||||
const LOG_BUFFER_SIZE = 50;
|
|
||||||
const logBuffer: Array<{ time: string; type: string; message: string }> = [];
|
|
||||||
|
|
||||||
function addToBuffer(type: string, message: string) {
|
|
||||||
const time = new Date().toLocaleTimeString();
|
|
||||||
logBuffer.unshift({ time, type, message });
|
|
||||||
if (logBuffer.length > LOG_BUFFER_SIZE) {
|
|
||||||
logBuffer.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRecentLogs() {
|
|
||||||
return logBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const logger = {
|
|
||||||
/**
|
|
||||||
* General information message
|
|
||||||
*/
|
|
||||||
info: (message: string, ...args: any[]) => {
|
|
||||||
console.log(`ℹ️ ${message}`, ...args);
|
|
||||||
addToBuffer("info", message);
|
|
||||||
try { WebServer.broadcastLog("info", message); } catch { }
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Success message
|
|
||||||
*/
|
|
||||||
success: (message: string, ...args: any[]) => {
|
|
||||||
console.log(`✅ ${message}`, ...args);
|
|
||||||
addToBuffer("success", message);
|
|
||||||
try { WebServer.broadcastLog("success", message); } catch { }
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Warning message
|
|
||||||
*/
|
|
||||||
warn: (message: string, ...args: any[]) => {
|
|
||||||
console.warn(`⚠️ ${message}`, ...args);
|
|
||||||
addToBuffer("warning", message);
|
|
||||||
try { WebServer.broadcastLog("warning", message); } catch { }
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error message
|
|
||||||
*/
|
|
||||||
error: (message: string, ...args: any[]) => {
|
|
||||||
console.error(`❌ ${message}`, ...args);
|
|
||||||
addToBuffer("error", message);
|
|
||||||
try { WebServer.broadcastLog("error", message); } catch { }
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Debug message
|
|
||||||
*/
|
|
||||||
debug: (message: string, ...args: any[]) => {
|
|
||||||
console.log(`🔍 ${message}`, ...args);
|
|
||||||
addToBuffer("debug", message);
|
|
||||||
try { WebServer.broadcastLog("debug", message); } catch { }
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { logger } from "@lib/logger";
|
|
||||||
|
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
let activeTransactions = 0;
|
let activeTransactions = 0;
|
||||||
@@ -22,7 +22,7 @@ export const waitForTransactions = async (timeoutMs: number = 10000) => {
|
|||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (activeTransactions > 0) {
|
while (activeTransactions > 0) {
|
||||||
if (Date.now() - start > timeoutMs) {
|
if (Date.now() - start > timeoutMs) {
|
||||||
logger.warn(`Shutdown timed out waiting for ${activeTransactions} transactions after ${timeoutMs}ms`);
|
console.warn(`Shutdown timed out waiting for ${activeTransactions} transactions after ${timeoutMs}ms`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { exec } from "child_process";
|
|||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { writeFile, readFile, unlink } from "fs/promises";
|
import { writeFile, readFile, unlink } from "fs/promises";
|
||||||
import { Client, TextChannel } from "discord.js";
|
import { Client, TextChannel } from "discord.js";
|
||||||
import { getPostRestartEmbed, getInstallingDependenciesEmbed } from "./update.view";
|
import { getPostRestartEmbed, getInstallingDependenciesEmbed, getRunningMigrationsEmbed } from "./update.view";
|
||||||
import type { PostRestartResult } from "./update.view";
|
import type { PostRestartResult } from "./update.view";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -16,72 +16,221 @@ export interface RestartContext {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
runMigrations: boolean;
|
runMigrations: boolean;
|
||||||
installDependencies: boolean;
|
installDependencies: boolean;
|
||||||
|
previousCommit: string;
|
||||||
|
newCommit: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateCheckResult {
|
export interface UpdateCheckResult {
|
||||||
needsInstall: boolean;
|
needsRootInstall: boolean;
|
||||||
|
needsWebInstall: boolean;
|
||||||
needsMigrations: boolean;
|
needsMigrations: boolean;
|
||||||
|
changedFiles: string[];
|
||||||
error?: Error;
|
error?: Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
hasUpdates: boolean;
|
||||||
|
branch: string;
|
||||||
|
currentCommit: string;
|
||||||
|
latestCommit: string;
|
||||||
|
commitCount: number;
|
||||||
|
commits: CommitInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitInfo {
|
||||||
|
hash: string;
|
||||||
|
message: string;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateService {
|
export class UpdateService {
|
||||||
private static readonly CONTEXT_FILE = ".restart_context.json";
|
private static readonly CONTEXT_FILE = ".restart_context.json";
|
||||||
|
private static readonly ROLLBACK_FILE = ".rollback_commit.txt";
|
||||||
|
|
||||||
static async checkForUpdates(): Promise<{ hasUpdates: boolean; log: string; branch: string }> {
|
/**
|
||||||
|
* Check for available updates with detailed commit information
|
||||||
|
*/
|
||||||
|
static async checkForUpdates(): Promise<UpdateInfo> {
|
||||||
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD");
|
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD");
|
||||||
const branch = branchName.trim();
|
const branch = branchName.trim();
|
||||||
|
|
||||||
|
const { stdout: currentCommit } = await execAsync("git rev-parse --short HEAD");
|
||||||
|
|
||||||
await execAsync("git fetch --all");
|
await execAsync("git fetch --all");
|
||||||
const { stdout: logOutput } = await execAsync(`git log HEAD..origin/${branch} --oneline`);
|
|
||||||
|
const { stdout: latestCommit } = await execAsync(`git rev-parse --short origin/${branch}`);
|
||||||
|
|
||||||
|
// Get commit log with author info
|
||||||
|
const { stdout: logOutput } = await execAsync(
|
||||||
|
`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`
|
||||||
|
);
|
||||||
|
|
||||||
|
const commits: CommitInfo[] = logOutput
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter(line => line.length > 0)
|
||||||
|
.map(line => {
|
||||||
|
const [hash, message, author] = line.split("|");
|
||||||
|
return { hash: hash || "", message: message || "", author: author || "" };
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasUpdates: !!logOutput.trim(),
|
hasUpdates: commits.length > 0,
|
||||||
log: logOutput.trim(),
|
branch,
|
||||||
branch
|
currentCommit: currentCommit.trim(),
|
||||||
|
latestCommit: latestCommit.trim(),
|
||||||
|
commitCount: commits.length,
|
||||||
|
commits
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async performUpdate(branch: string): Promise<void> {
|
/**
|
||||||
await execAsync(`git reset --hard origin/${branch}`);
|
* Analyze what the update requires
|
||||||
}
|
*/
|
||||||
|
|
||||||
static async checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
|
static async checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`);
|
const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`);
|
||||||
|
const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0);
|
||||||
|
|
||||||
|
const needsRootInstall = changedFiles.some(file =>
|
||||||
|
file === "package.json" || file === "bun.lock"
|
||||||
|
);
|
||||||
|
|
||||||
|
const needsWebInstall = changedFiles.some(file =>
|
||||||
|
file === "src/web/package.json" || file === "src/web/bun.lock"
|
||||||
|
);
|
||||||
|
|
||||||
|
const needsMigrations = changedFiles.some(file =>
|
||||||
|
file.includes("schema.ts") || file.startsWith("drizzle/")
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
needsInstall: stdout.includes("package.json"),
|
needsRootInstall,
|
||||||
needsMigrations: stdout.includes("schema.ts") || stdout.includes("drizzle/")
|
needsWebInstall,
|
||||||
|
needsMigrations,
|
||||||
|
changedFiles
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to check update requirements:", e);
|
console.error("Failed to check update requirements:", e);
|
||||||
return {
|
return {
|
||||||
needsInstall: false,
|
needsRootInstall: false,
|
||||||
|
needsWebInstall: false,
|
||||||
needsMigrations: false,
|
needsMigrations: false,
|
||||||
|
changedFiles: [],
|
||||||
error: e instanceof Error ? e : new Error(String(e))
|
error: e instanceof Error ? e : new Error(String(e))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async installDependencies(): Promise<string> {
|
/**
|
||||||
const { stdout } = await execAsync("bun install");
|
* Get a summary of changed file categories
|
||||||
return stdout;
|
*/
|
||||||
|
static categorizeChanges(changedFiles: string[]): Record<string, number> {
|
||||||
|
const categories: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const file of changedFiles) {
|
||||||
|
let category = "Other";
|
||||||
|
|
||||||
|
if (file.startsWith("src/commands/")) category = "Commands";
|
||||||
|
else if (file.startsWith("src/modules/")) category = "Modules";
|
||||||
|
else if (file.startsWith("src/web/")) category = "Web Dashboard";
|
||||||
|
else if (file.startsWith("src/lib/")) category = "Library";
|
||||||
|
else if (file.startsWith("drizzle/") || file.includes("schema")) category = "Database";
|
||||||
|
else if (file.endsWith(".test.ts")) category = "Tests";
|
||||||
|
else if (file.includes("package.json") || file.includes("lock")) category = "Dependencies";
|
||||||
|
|
||||||
|
categories[category] = (categories[category] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the current commit for potential rollback
|
||||||
|
*/
|
||||||
|
static async saveRollbackPoint(): Promise<string> {
|
||||||
|
const { stdout } = await execAsync("git rev-parse HEAD");
|
||||||
|
const commit = stdout.trim();
|
||||||
|
await writeFile(this.ROLLBACK_FILE, commit);
|
||||||
|
return commit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback to the previous commit
|
||||||
|
*/
|
||||||
|
static async rollback(): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
const rollbackCommit = await readFile(this.ROLLBACK_FILE, "utf-8");
|
||||||
|
await execAsync(`git reset --hard ${rollbackCommit.trim()}`);
|
||||||
|
await unlink(this.ROLLBACK_FILE);
|
||||||
|
return { success: true, message: `Rolled back to ${rollbackCommit.trim().substring(0, 7)}` };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: e instanceof Error ? e.message : "No rollback point available"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a rollback point exists
|
||||||
|
*/
|
||||||
|
static async hasRollbackPoint(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await readFile(this.ROLLBACK_FILE, "utf-8");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform the git update
|
||||||
|
*/
|
||||||
|
static async performUpdate(branch: string): Promise<void> {
|
||||||
|
await execAsync(`git reset --hard origin/${branch}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install dependencies for specified projects
|
||||||
|
*/
|
||||||
|
static async installDependencies(options: { root: boolean; web: boolean }): Promise<string> {
|
||||||
|
const outputs: string[] = [];
|
||||||
|
|
||||||
|
if (options.root) {
|
||||||
|
const { stdout } = await execAsync("bun install");
|
||||||
|
outputs.push(`📦 Root: ${stdout.trim() || "Done"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.web) {
|
||||||
|
const { stdout } = await execAsync("cd src/web && bun install");
|
||||||
|
outputs.push(`🌐 Web: ${stdout.trim() || "Done"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputs.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare restart context with rollback info
|
||||||
|
*/
|
||||||
static async prepareRestartContext(context: RestartContext): Promise<void> {
|
static async prepareRestartContext(context: RestartContext): Promise<void> {
|
||||||
await writeFile(this.CONTEXT_FILE, JSON.stringify(context));
|
await writeFile(this.CONTEXT_FILE, JSON.stringify(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a restart
|
||||||
|
*/
|
||||||
static async triggerRestart(): Promise<void> {
|
static async triggerRestart(): Promise<void> {
|
||||||
if (process.env.RESTART_COMMAND) {
|
if (process.env.RESTART_COMMAND) {
|
||||||
// Run without awaiting - it may kill the process immediately
|
|
||||||
exec(process.env.RESTART_COMMAND).unref();
|
exec(process.env.RESTART_COMMAND).unref();
|
||||||
} else {
|
} else {
|
||||||
// Fallback: exit the process and let Docker/PM2/systemd restart it
|
|
||||||
// Small delay to allow any pending I/O to complete
|
|
||||||
setTimeout(() => process.exit(0), 100);
|
setTimeout(() => process.exit(0), 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle post-restart tasks
|
||||||
|
*/
|
||||||
static async handlePostRestart(client: Client): Promise<void> {
|
static async handlePostRestart(client: Client): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const context = await this.loadRestartContext();
|
const context = await this.loadRestartContext();
|
||||||
@@ -99,7 +248,7 @@ export class UpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.executePostRestartTasks(context, channel);
|
const result = await this.executePostRestartTasks(context, channel);
|
||||||
await this.notifyPostRestartResult(channel, result);
|
await this.notifyPostRestartResult(channel, result, context);
|
||||||
await this.cleanupContext();
|
await this.cleanupContext();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to handle post-restart context:", e);
|
console.error("Failed to handle post-restart context:", e);
|
||||||
@@ -143,15 +292,20 @@ export class UpdateService {
|
|||||||
migrationSuccess: true,
|
migrationSuccess: true,
|
||||||
migrationOutput: "",
|
migrationOutput: "",
|
||||||
ranInstall: context.installDependencies,
|
ranInstall: context.installDependencies,
|
||||||
ranMigrations: context.runMigrations
|
ranMigrations: context.runMigrations,
|
||||||
|
previousCommit: context.previousCommit,
|
||||||
|
newCommit: context.newCommit
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Install Dependencies if needed
|
// 1. Install Dependencies if needed
|
||||||
if (context.installDependencies) {
|
if (context.installDependencies) {
|
||||||
try {
|
try {
|
||||||
await channel.send({ embeds: [getInstallingDependenciesEmbed()] });
|
await channel.send({ embeds: [getInstallingDependenciesEmbed()] });
|
||||||
const { stdout } = await execAsync("bun install");
|
|
||||||
result.installOutput = stdout;
|
const { stdout: rootOutput } = await execAsync("bun install");
|
||||||
|
const { stdout: webOutput } = await execAsync("cd src/web && bun install");
|
||||||
|
|
||||||
|
result.installOutput = `📦 Root: ${rootOutput.trim() || "Done"}\n🌐 Web: ${webOutput.trim() || "Done"}`;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
result.installSuccess = false;
|
result.installSuccess = false;
|
||||||
result.installOutput = err instanceof Error ? err.message : String(err);
|
result.installOutput = err instanceof Error ? err.message : String(err);
|
||||||
@@ -162,6 +316,7 @@ export class UpdateService {
|
|||||||
// 2. Run Migrations
|
// 2. Run Migrations
|
||||||
if (context.runMigrations) {
|
if (context.runMigrations) {
|
||||||
try {
|
try {
|
||||||
|
await channel.send({ embeds: [getRunningMigrationsEmbed()] });
|
||||||
const { stdout } = await execAsync("bun x drizzle-kit migrate");
|
const { stdout } = await execAsync("bun x drizzle-kit migrate");
|
||||||
result.migrationOutput = stdout;
|
result.migrationOutput = stdout;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -174,8 +329,13 @@ export class UpdateService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async notifyPostRestartResult(channel: TextChannel, result: PostRestartResult): Promise<void> {
|
private static async notifyPostRestartResult(
|
||||||
await channel.send({ embeds: [getPostRestartEmbed(result)] });
|
channel: TextChannel,
|
||||||
|
result: PostRestartResult,
|
||||||
|
context: RestartContext
|
||||||
|
): Promise<void> {
|
||||||
|
const hasRollback = await this.hasRollbackPoint();
|
||||||
|
await channel.send(getPostRestartEmbed(result, hasRollback));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async cleanupContext(): Promise<void> {
|
private static async cleanupContext(): Promise<void> {
|
||||||
|
|||||||
@@ -1,31 +1,100 @@
|
|||||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
|
||||||
import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
||||||
|
import type { UpdateInfo, UpdateCheckResult } from "./update.service";
|
||||||
|
|
||||||
// Constants for UI
|
// Constants for UI
|
||||||
const LOG_TRUNCATE_LENGTH = 1000;
|
const LOG_TRUNCATE_LENGTH = 800;
|
||||||
const OUTPUT_TRUNCATE_LENGTH = 500;
|
const OUTPUT_TRUNCATE_LENGTH = 400;
|
||||||
|
|
||||||
function truncate(text: string, maxLength: number): string {
|
function truncate(text: string, maxLength: number): string {
|
||||||
return text.length > maxLength ? `${text.substring(0, maxLength)}\n...and more` : text;
|
if (!text) return "";
|
||||||
|
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Pre-Update Embeds ============
|
||||||
|
|
||||||
export function getCheckingEmbed() {
|
export function getCheckingEmbed() {
|
||||||
return createInfoEmbed("Checking for updates...", "System Update");
|
return createInfoEmbed("🔍 Fetching latest changes from remote...", "Checking for Updates");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNoUpdatesEmbed() {
|
export function getNoUpdatesEmbed(currentCommit: string) {
|
||||||
return createSuccessEmbed("The bot is already up to date.", "No Updates Found");
|
return createSuccessEmbed(
|
||||||
}
|
`You're running the latest version.\n\n**Current:** \`${currentCommit}\``,
|
||||||
|
"✅ Already Up to Date"
|
||||||
export function getUpdatesAvailableMessage(branch: string, log: string, force: boolean) {
|
|
||||||
const embed = createInfoEmbed(
|
|
||||||
`**Branch:** \`${branch}\`\n\n**Pending Changes:**\n\`\`\`\n${truncate(log, LOG_TRUNCATE_LENGTH)}\n\`\`\`\n**Do you want to proceed?**`,
|
|
||||||
"Updates Available"
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUpdatesAvailableMessage(
|
||||||
|
updateInfo: UpdateInfo,
|
||||||
|
requirements: UpdateCheckResult,
|
||||||
|
changeCategories: Record<string, number>,
|
||||||
|
force: boolean
|
||||||
|
) {
|
||||||
|
const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo;
|
||||||
|
const { needsRootInstall, needsWebInstall, needsMigrations } = requirements;
|
||||||
|
|
||||||
|
// Build commit list (max 5)
|
||||||
|
const commitList = commits
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(c => `\`${c.hash}\` ${truncate(c.message, 50)}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const moreCommits = commitCount > 5 ? `\n*...and ${commitCount - 5} more*` : "";
|
||||||
|
|
||||||
|
// Build change categories
|
||||||
|
const categoryList = Object.entries(changeCategories)
|
||||||
|
.map(([cat, count]) => `• ${cat}: ${count} file${count > 1 ? "s" : ""}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// Build requirements list
|
||||||
|
const reqs: string[] = [];
|
||||||
|
if (needsRootInstall) reqs.push("📦 Install root dependencies");
|
||||||
|
if (needsWebInstall) reqs.push("🌐 Install web dependencies");
|
||||||
|
if (needsMigrations) reqs.push("🗃️ Run database migrations");
|
||||||
|
if (reqs.length === 0) reqs.push("⚡ Quick update (no extra steps)");
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle("📥 Updates Available")
|
||||||
|
.setColor(force ? 0xFF6B6B : 0x5865F2)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: "Version",
|
||||||
|
value: `\`${currentCommit}\` → \`${latestCommit}\``,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Branch",
|
||||||
|
value: `\`${branch}\``,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Commits",
|
||||||
|
value: `${commitCount} new commit${commitCount > 1 ? "s" : ""}`,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Recent Changes",
|
||||||
|
value: commitList + moreCommits || "No commits",
|
||||||
|
inline: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Files Changed",
|
||||||
|
value: categoryList || "Unknown",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update Actions",
|
||||||
|
value: reqs.join("\n"),
|
||||||
|
inline: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.setFooter({ text: force ? "⚠️ Force mode enabled" : "This will restart the bot" })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
const confirmButton = new ButtonBuilder()
|
const confirmButton = new ButtonBuilder()
|
||||||
.setCustomId("confirm_update")
|
.setCustomId("confirm_update")
|
||||||
.setLabel(force ? "Force Update & Restart" : "Update & Restart")
|
.setLabel(force ? "Force Update" : "Update Now")
|
||||||
|
.setEmoji(force ? "⚠️" : "🚀")
|
||||||
.setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success);
|
.setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success);
|
||||||
|
|
||||||
const cancelButton = new ButtonBuilder()
|
const cancelButton = new ButtonBuilder()
|
||||||
@@ -33,34 +102,58 @@ export function getUpdatesAvailableMessage(branch: string, log: string, force: b
|
|||||||
.setLabel("Cancel")
|
.setLabel("Cancel")
|
||||||
.setStyle(ButtonStyle.Secondary);
|
.setStyle(ButtonStyle.Secondary);
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(confirmButton, cancelButton);
|
||||||
.addComponents(confirmButton, cancelButton);
|
|
||||||
|
|
||||||
return { embeds: [embed], components: [row] };
|
return { embeds: [embed], components: [row] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Update Progress Embeds ============
|
||||||
|
|
||||||
export function getPreparingEmbed() {
|
export function getPreparingEmbed() {
|
||||||
return createInfoEmbed("⏳ Preparing update...", "Update In Progress");
|
return createInfoEmbed(
|
||||||
|
"🔒 Saving rollback point...\n📥 Preparing to download updates...",
|
||||||
|
"⏳ Preparing Update"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUpdatingEmbed(needsDependencyInstall: boolean) {
|
export function getUpdatingEmbed(requirements: UpdateCheckResult) {
|
||||||
const message = `Downloading and applying updates...${needsDependencyInstall ? `\nExpect a slightly longer startup for dependency installation.` : ""}\nThe system will restart automatically.`;
|
const steps: string[] = ["✅ Rollback point saved"];
|
||||||
return createWarningEmbed(message, "Updating & Restarting");
|
|
||||||
|
steps.push("📥 Downloading updates...");
|
||||||
|
|
||||||
|
if (requirements.needsRootInstall || requirements.needsWebInstall) {
|
||||||
|
steps.push("📦 Dependencies will be installed after restart");
|
||||||
|
}
|
||||||
|
if (requirements.needsMigrations) {
|
||||||
|
steps.push("🗃️ Migrations will run after restart");
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push("\n🔄 **Restarting now...**");
|
||||||
|
|
||||||
|
return createWarningEmbed(steps.join("\n"), "🚀 Updating");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCancelledEmbed() {
|
export function getCancelledEmbed() {
|
||||||
return createInfoEmbed("Update cancelled.", "Cancelled");
|
return createInfoEmbed("Update cancelled. No changes were made.", "❌ Cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTimeoutEmbed() {
|
export function getTimeoutEmbed() {
|
||||||
return createWarningEmbed("Update confirmation timed out.", "Timed Out");
|
return createWarningEmbed(
|
||||||
|
"No response received within 30 seconds.\nRun `/update` again when ready.",
|
||||||
|
"⏰ Timed Out"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getErrorEmbed(error: unknown) {
|
export function getErrorEmbed(error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
return createErrorEmbed(`Failed to update:\n\`\`\`\n${message}\n\`\`\``, "Update Failed");
|
return createErrorEmbed(
|
||||||
|
`The update could not be completed:\n\`\`\`\n${truncate(message, 500)}\n\`\`\``,
|
||||||
|
"❌ Update Failed"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Post-Restart Embeds ============
|
||||||
|
|
||||||
export interface PostRestartResult {
|
export interface PostRestartResult {
|
||||||
installSuccess: boolean;
|
installSuccess: boolean;
|
||||||
installOutput: string;
|
installOutput: string;
|
||||||
@@ -68,33 +161,114 @@ export interface PostRestartResult {
|
|||||||
migrationOutput: string;
|
migrationOutput: string;
|
||||||
ranInstall: boolean;
|
ranInstall: boolean;
|
||||||
ranMigrations: boolean;
|
ranMigrations: boolean;
|
||||||
|
previousCommit?: string;
|
||||||
|
newCommit?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPostRestartEmbed(result: PostRestartResult) {
|
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
|
||||||
const parts: string[] = ["System updated successfully."];
|
const isSuccess = result.installSuccess && result.migrationSuccess;
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(isSuccess ? "✅ Update Complete" : "⚠️ Update Completed with Issues")
|
||||||
|
.setColor(isSuccess ? 0x57F287 : 0xFEE75C)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
// Version info
|
||||||
|
if (result.previousCommit && result.newCommit) {
|
||||||
|
embed.addFields({
|
||||||
|
name: "Version",
|
||||||
|
value: `\`${result.previousCommit}\` → \`${result.newCommit}\``,
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Results summary
|
||||||
|
const results: string[] = [];
|
||||||
|
|
||||||
if (result.ranInstall) {
|
if (result.ranInstall) {
|
||||||
parts.push(`**Dependencies:** ${result.installSuccess ? "✅ Installed" : "❌ Failed"}`);
|
results.push(result.installSuccess
|
||||||
|
? "✅ Dependencies installed"
|
||||||
|
: "❌ Dependency installation failed"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.ranMigrations) {
|
if (result.ranMigrations) {
|
||||||
parts.push(`**Migrations:** ${result.migrationSuccess ? "✅ Applied" : "❌ Failed"}`);
|
results.push(result.migrationSuccess
|
||||||
|
? "✅ Migrations applied"
|
||||||
|
: "❌ Migration failed"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.installOutput) {
|
if (results.length > 0) {
|
||||||
parts.push(`\n**Install Output:**\n\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``);
|
embed.addFields({
|
||||||
|
name: "Actions Performed",
|
||||||
|
value: results.join("\n"),
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.migrationOutput) {
|
// Output details (collapsed if too long)
|
||||||
parts.push(`\n**Migration Output:**\n\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``);
|
if (result.installOutput && !result.installSuccess) {
|
||||||
|
embed.addFields({
|
||||||
|
name: "Install Output",
|
||||||
|
value: `\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSuccess = result.installSuccess && result.migrationSuccess;
|
if (result.migrationOutput && !result.migrationSuccess) {
|
||||||
const title = isSuccess ? "Update Complete" : "Update Completed with Errors";
|
embed.addFields({
|
||||||
|
name: "Migration Output",
|
||||||
|
value: `\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return createSuccessEmbed(parts.join("\n"), title);
|
// Footer with rollback hint
|
||||||
|
if (!isSuccess && hasRollback) {
|
||||||
|
embed.setFooter({ text: "💡 Use /update rollback to revert if needed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build components
|
||||||
|
const components: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||||
|
|
||||||
|
if (!isSuccess && hasRollback) {
|
||||||
|
const rollbackButton = new ButtonBuilder()
|
||||||
|
.setCustomId("rollback_update")
|
||||||
|
.setLabel("Rollback")
|
||||||
|
.setEmoji("↩️")
|
||||||
|
.setStyle(ButtonStyle.Danger);
|
||||||
|
|
||||||
|
components.push(new ActionRowBuilder<ButtonBuilder>().addComponents(rollbackButton));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { embeds: [embed], components };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInstallingDependenciesEmbed() {
|
export function getInstallingDependenciesEmbed() {
|
||||||
return createSuccessEmbed("Installing dependencies...", "Post-Update Action");
|
return createInfoEmbed(
|
||||||
|
"📦 Installing dependencies for root and web projects...\nThis may take a moment.",
|
||||||
|
"⏳ Installing Dependencies"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRunningMigrationsEmbed() {
|
||||||
|
return createInfoEmbed(
|
||||||
|
"🗃️ Applying database migrations...",
|
||||||
|
"⏳ Running Migrations"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRollbackSuccessEmbed(commit: string) {
|
||||||
|
return createSuccessEmbed(
|
||||||
|
`Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`,
|
||||||
|
"↩️ Rollback Complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRollbackFailedEmbed(error: string) {
|
||||||
|
return createErrorEmbed(
|
||||||
|
`Could not rollback:\n\`\`\`\n${error}\n\`\`\``,
|
||||||
|
"❌ Rollback Failed"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/web/.gitignore
vendored
Normal file
34
src/web/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
21
src/web/README.md
Normal file
21
src/web/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Aurora Web
|
||||||
|
|
||||||
|
To install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
To start a development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
To run for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun start
|
||||||
|
```
|
||||||
|
|
||||||
|
This project was created using `bun init` in bun v1.3.3. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||||
149
src/web/build.ts
Normal file
149
src/web/build.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import plugin from "bun-plugin-tailwind";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
import { rm } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
||||||
|
console.log(`
|
||||||
|
🏗️ Bun Build Script
|
||||||
|
|
||||||
|
Usage: bun run build.ts [options]
|
||||||
|
|
||||||
|
Common Options:
|
||||||
|
--outdir <path> Output directory (default: "dist")
|
||||||
|
--minify Enable minification (or --minify.whitespace, --minify.syntax, etc)
|
||||||
|
--sourcemap <type> Sourcemap type: none|linked|inline|external
|
||||||
|
--target <target> Build target: browser|bun|node
|
||||||
|
--format <format> Output format: esm|cjs|iife
|
||||||
|
--splitting Enable code splitting
|
||||||
|
--packages <type> Package handling: bundle|external
|
||||||
|
--public-path <path> Public path for assets
|
||||||
|
--env <mode> Environment handling: inline|disable|prefix*
|
||||||
|
--conditions <list> Package.json export conditions (comma separated)
|
||||||
|
--external <list> External packages (comma separated)
|
||||||
|
--banner <text> Add banner text to output
|
||||||
|
--footer <text> Add footer text to output
|
||||||
|
--define <obj> Define global constants (e.g. --define.VERSION=1.0.0)
|
||||||
|
--help, -h Show this help message
|
||||||
|
|
||||||
|
Example:
|
||||||
|
bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom
|
||||||
|
`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, g => g[1].toUpperCase());
|
||||||
|
|
||||||
|
const parseValue = (value: string): any => {
|
||||||
|
if (value === "true") return true;
|
||||||
|
if (value === "false") return false;
|
||||||
|
|
||||||
|
if (/^\d+$/.test(value)) return parseInt(value, 10);
|
||||||
|
if (/^\d*\.\d+$/.test(value)) return parseFloat(value);
|
||||||
|
|
||||||
|
if (value.includes(",")) return value.split(",").map(v => v.trim());
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(): Partial<Bun.BuildConfig> {
|
||||||
|
const config: Partial<Bun.BuildConfig> = {};
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === undefined) continue;
|
||||||
|
if (!arg.startsWith("--")) continue;
|
||||||
|
|
||||||
|
if (arg.startsWith("--no-")) {
|
||||||
|
const key = toCamelCase(arg.slice(5));
|
||||||
|
config[key] = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) {
|
||||||
|
const key = toCamelCase(arg.slice(2));
|
||||||
|
config[key] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key: string;
|
||||||
|
let value: string;
|
||||||
|
|
||||||
|
if (arg.includes("=")) {
|
||||||
|
[key, value] = arg.slice(2).split("=", 2) as [string, string];
|
||||||
|
} else {
|
||||||
|
key = arg.slice(2);
|
||||||
|
value = args[++i] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
key = toCamelCase(key);
|
||||||
|
|
||||||
|
if (key.includes(".")) {
|
||||||
|
const [parentKey, childKey] = key.split(".");
|
||||||
|
config[parentKey] = config[parentKey] || {};
|
||||||
|
config[parentKey][childKey] = parseValue(value);
|
||||||
|
} else {
|
||||||
|
config[key] = parseValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("\n🚀 Starting build process...\n");
|
||||||
|
|
||||||
|
const cliConfig = parseArgs();
|
||||||
|
const outdir = cliConfig.outdir || path.join(process.cwd(), "dist");
|
||||||
|
|
||||||
|
if (existsSync(outdir)) {
|
||||||
|
console.log(`🗑️ Cleaning previous build at ${outdir}`);
|
||||||
|
await rm(outdir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
|
||||||
|
.map(a => path.resolve("src", a))
|
||||||
|
.filter(dir => !dir.includes("node_modules"));
|
||||||
|
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
|
||||||
|
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints,
|
||||||
|
outdir,
|
||||||
|
plugins: [plugin],
|
||||||
|
minify: true,
|
||||||
|
target: "browser",
|
||||||
|
sourcemap: "linked",
|
||||||
|
define: {
|
||||||
|
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||||
|
},
|
||||||
|
...cliConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
const end = performance.now();
|
||||||
|
|
||||||
|
const outputTable = result.outputs.map(output => ({
|
||||||
|
File: path.relative(process.cwd(), output.path),
|
||||||
|
Type: output.kind,
|
||||||
|
Size: formatFileSize(output.size),
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.table(outputTable);
|
||||||
|
const buildTime = (end - start).toFixed(2);
|
||||||
|
|
||||||
|
console.log(`\n✅ Build completed in ${buildTime}ms\n`);
|
||||||
17
src/web/bun-env.d.ts
vendored
Normal file
17
src/web/bun-env.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Generated by `bun init`
|
||||||
|
|
||||||
|
declare module "*.svg" {
|
||||||
|
/**
|
||||||
|
* A path to the SVG file
|
||||||
|
*/
|
||||||
|
const path: `${string}.svg`;
|
||||||
|
export = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.module.css" {
|
||||||
|
/**
|
||||||
|
* A record of class names to their corresponding CSS module classes
|
||||||
|
*/
|
||||||
|
const classes: { readonly [key: string]: string };
|
||||||
|
export = classes;
|
||||||
|
}
|
||||||
201
src/web/bun.lock
Normal file
201
src/web/bun.lock
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "bun-react-template",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"bun-plugin-tailwind": "^0.1.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"react": "^19",
|
||||||
|
"react-dom": "^19",
|
||||||
|
"react-router-dom": "^7.12.0",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"tailwindcss": "^4.1.11",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
|
||||||
|
|
||||||
|
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
|
||||||
|
|
||||||
|
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
|
||||||
|
|
||||||
|
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||||
|
|
||||||
|
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8GvNtMo0NINM7Emk9cNAviCG3teEgr3BUX9be0+GD029zIagx2Sf54jMui1Eu1IpFm7nWHODuLEefGOQNaJ0gQ=="],
|
||||||
|
|
||||||
|
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-r33eHQOHAwkuiBJIwmkXIyqONQOQMnd1GMTpDzaxx9vf9+svby80LZO9Hcm1ns6KT/TBRFyODC/0loA7FAaffg=="],
|
||||||
|
|
||||||
|
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-p5q3rJk48qhLuLBOFehVc+kqCE03YrswTc6NCxbwsxiwfySXwcAvpF2KWKF/ZZObvvR8hCCvqe1F81b2p5r2dg=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-zkcHPI23QxJ1TdqafhgkXt1NOEN8o5C460sVeNnrhfJ43LwZgtfcvcQE39x/pBedu67fatY8CU0iY00nOh46ZQ=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-HKBeUlJdNduRkzJKZ5DXM+pPqntfC50/Hu2X65jVX0Y7hu/6IC8RaUTqpr8FtCZqqmc9wDK0OTL+Mbi9UQIKYQ=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-n7zhKTSDZS0yOYg5Rq8easZu5Y/o47sv0c7yGr2ciFdcie9uYV55fZ7QMqhWMGK33ezCSikh5EDkUMCIvfWpjA=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FeCQyBU62DMuB0nn01vPnf3McXrKOsrK9p7sHaBFYycw0mmoU8kCq/WkBkGMnLuvQljJSyen8QBTx+fXdNupWg=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-XkCCHkByYn8BIDvoxnny898znju4xnW2kvFE8FT5+0Y62cWdcBGMZ9RdsEUTeRz16k8hHtJpaSfLcEmNTFIwRQ=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-TJiYC7KCr0XxFTsxgwQOeE7dncrEL/RSyL0EzSL3xRkrxJMWBCvCSjQn7LV1i6T7hFst0+3KoN3VWvD5BinqHA=="],
|
||||||
|
|
||||||
|
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-T3xkODItb/0ftQPFsZDc7EAX2D6A4TEazQ2YZyofZToO8Q7y8YT8ooWdhd0BQiTCd66uEvgE1DCZetynwg2IoA=="],
|
||||||
|
|
||||||
|
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-rtVQB9/1XK8FWJgFtsOthbPifRMYypgJwxu+pK3NHx8WvFKmq7HcPDqNr8xLzGULjQEO7eAo2aOZfONOwYz+5g=="],
|
||||||
|
|
||||||
|
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||||
|
|
||||||
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||||
|
|
||||||
|
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
|
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||||
|
|
||||||
|
"bun": ["bun@1.3.5", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.5", "@oven/bun-darwin-x64": "1.3.5", "@oven/bun-darwin-x64-baseline": "1.3.5", "@oven/bun-linux-aarch64": "1.3.5", "@oven/bun-linux-aarch64-musl": "1.3.5", "@oven/bun-linux-x64": "1.3.5", "@oven/bun-linux-x64-baseline": "1.3.5", "@oven/bun-linux-x64-musl": "1.3.5", "@oven/bun-linux-x64-musl-baseline": "1.3.5", "@oven/bun-windows-x64": "1.3.5", "@oven/bun-windows-x64-baseline": "1.3.5" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-c1YHIGUfgvYPJmLug5QiLzNWlX2Dg7X/67JWu1Va+AmMXNXzC/KQn2lgQ7rD+n1u1UqDpJMowVGGxTNpbPydNw=="],
|
||||||
|
|
||||||
|
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||||
|
|
||||||
|
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
|
|
||||||
|
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
|
||||||
|
|
||||||
|
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||||
|
|
||||||
|
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||||
|
|
||||||
|
"react-router": ["react-router@7.12.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw=="],
|
||||||
|
|
||||||
|
"react-router-dom": ["react-router-dom@7.12.0", "", { "dependencies": { "react-router": "7.12.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA=="],
|
||||||
|
|
||||||
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||||
|
|
||||||
|
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||||
|
|
||||||
|
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/web/bunfig.toml
Normal file
3
src/web/bunfig.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[serve.static]
|
||||||
|
plugins = ["bun-plugin-tailwind"]
|
||||||
|
env = "BUN_PUBLIC_*"
|
||||||
21
src/web/components.json
Normal file
21
src/web/components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "styles/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
34
src/web/package.json
Normal file
34
src/web/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "bun-react-template",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun --hot src/index.ts",
|
||||||
|
"start": "NODE_ENV=production bun src/index.ts",
|
||||||
|
"build": "bun run build.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"bun-plugin-tailwind": "^0.1.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"react": "^19",
|
||||||
|
"react-dom": "^19",
|
||||||
|
"react-router-dom": "^7.12.0",
|
||||||
|
"tailwind-merge": "^3.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"tailwindcss": "^4.1.11",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
function formatUptime(seconds) {
|
|
||||||
if (seconds < 0) return "0s";
|
|
||||||
|
|
||||||
const days = Math.floor(seconds / (3600 * 24));
|
|
||||||
const hours = Math.floor((seconds % (3600 * 24)) / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
|
|
||||||
const parts = [];
|
|
||||||
if (days > 0) parts.push(`${days}d`);
|
|
||||||
if (hours > 0) parts.push(`${hours}h`);
|
|
||||||
if (minutes > 0) parts.push(`${minutes}m`);
|
|
||||||
parts.push(`${secs}s`);
|
|
||||||
|
|
||||||
return parts.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUptime() {
|
|
||||||
const el = document.getElementById("uptime-display");
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
const startTimestamp = parseInt(el.getAttribute("data-start-timestamp"), 10);
|
|
||||||
if (isNaN(startTimestamp)) return;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const elapsedSeconds = (now - startTimestamp) / 1000;
|
|
||||||
|
|
||||||
el.textContent = formatUptime(elapsedSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
// Update immediately to prevent stale content flash if possible
|
|
||||||
updateUptime();
|
|
||||||
// Update every second
|
|
||||||
setInterval(updateUptime, 1000);
|
|
||||||
|
|
||||||
// WebSocket Connection
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
|
||||||
|
|
||||||
function connectWs() {
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
const statusIndicator = document.querySelector(".status-indicator");
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
console.log("WS Connected");
|
|
||||||
if (statusIndicator) statusIndicator.classList.add("online");
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.type === "HEARTBEAT") {
|
|
||||||
console.log("Heartbeat:", msg.data);
|
|
||||||
// Sync uptime?
|
|
||||||
// We can optionally verify if client clock is drifting, but let's keep it simple.
|
|
||||||
} else if (msg.type === "WELCOME") {
|
|
||||||
console.log(msg.message);
|
|
||||||
} else if (msg.type === "LOG") {
|
|
||||||
appendToActivityFeed(msg.data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("WS Parse Error", e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function appendToActivityFeed(log) {
|
|
||||||
const list = document.querySelector(".activity-feed");
|
|
||||||
if (!list) return;
|
|
||||||
|
|
||||||
const item = document.createElement("li");
|
|
||||||
item.className = `activity-item ${log.type}`;
|
|
||||||
|
|
||||||
const timeSpan = document.createElement("span");
|
|
||||||
timeSpan.className = "time";
|
|
||||||
timeSpan.textContent = log.timestamp;
|
|
||||||
|
|
||||||
const messageSpan = document.createElement("span");
|
|
||||||
messageSpan.className = "message";
|
|
||||||
messageSpan.textContent = log.message;
|
|
||||||
|
|
||||||
item.appendChild(timeSpan);
|
|
||||||
item.appendChild(messageSpan);
|
|
||||||
|
|
||||||
// Prepend to top
|
|
||||||
list.insertBefore(item, list.firstChild);
|
|
||||||
|
|
||||||
// Limit history
|
|
||||||
if (list.children.length > 50) {
|
|
||||||
list.removeChild(list.lastChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
console.log("WS Disconnected");
|
|
||||||
if (statusIndicator) statusIndicator.classList.remove("online");
|
|
||||||
// Retry in 5s
|
|
||||||
setTimeout(connectWs, 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (err) => {
|
|
||||||
console.error("WS Error", err);
|
|
||||||
ws.close();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
connectWs();
|
|
||||||
});
|
|
||||||
@@ -1,607 +0,0 @@
|
|||||||
:root {
|
|
||||||
/* Color Palette - HSL (Hue, Saturation, Lightness) */
|
|
||||||
/* Primary (Aurora Cyan) */
|
|
||||||
--primary-h: 180;
|
|
||||||
--primary-s: 100%;
|
|
||||||
--primary-l: 50%;
|
|
||||||
--primary: hsl(var(--primary-h), var(--primary-s), var(--primary-l));
|
|
||||||
|
|
||||||
/* Secondary (Aurora Purple) */
|
|
||||||
--secondary-h: 270;
|
|
||||||
--secondary-s: 100%;
|
|
||||||
--secondary-l: 65%;
|
|
||||||
--secondary: hsl(var(--secondary-h), var(--secondary-s), var(--secondary-l));
|
|
||||||
|
|
||||||
/* Backgrounds (Dark Slate) */
|
|
||||||
--bg-h: 222;
|
|
||||||
--bg-s: 47%;
|
|
||||||
--bg-l: 7%;
|
|
||||||
/* Very Dark */
|
|
||||||
--bg-color: hsl(var(--bg-h), var(--bg-s), var(--bg-l));
|
|
||||||
|
|
||||||
--card-bg-h: 217;
|
|
||||||
--card-bg-s: 33%;
|
|
||||||
--card-bg-l: 15%;
|
|
||||||
--card-bg: hsl(var(--card-bg-h), var(--card-bg-s), var(--card-bg-l));
|
|
||||||
|
|
||||||
/* Text */
|
|
||||||
--text-main: hsl(210, 40%, 98%);
|
|
||||||
--text-muted: hsl(215, 20%, 65%);
|
|
||||||
--text-accent: var(--primary);
|
|
||||||
|
|
||||||
/* Borders */
|
|
||||||
--border-color: hsl(215, 25%, 25%);
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
--font-heading: 'Outfit', system-ui, sans-serif;
|
|
||||||
--font-body: 'Inter', system-ui, sans-serif;
|
|
||||||
|
|
||||||
/* Spacing & Radii */
|
|
||||||
--radius-md: 0.75rem;
|
|
||||||
--radius-lg: 1rem;
|
|
||||||
--header-height: 4rem;
|
|
||||||
|
|
||||||
/* Effects */
|
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.2), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
||||||
--shadow-glow: 0 0 15px hsla(var(--primary-h), var(--primary-s), 50%, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
color: var(--text-main);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.6;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
margin-top: 0;
|
|
||||||
line-height: 1.2;
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
header {
|
|
||||||
background: rgba(15, 23, 42, 0.8);
|
|
||||||
/* Semi-transparent */
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
height: var(--header-height);
|
|
||||||
padding: 0 2rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin: 0;
|
|
||||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
header nav a {
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-left: 1.5rem;
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
header nav a:hover {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main Layout */
|
|
||||||
main {
|
|
||||||
flex: 1;
|
|
||||||
padding: 2rem;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card Component */
|
|
||||||
.card {
|
|
||||||
background-color: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-glow), var(--shadow-md);
|
|
||||||
border-color: hsla(var(--primary-h), var(--primary-s), 50%, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h2 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--text-main);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card p {
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Links */
|
|
||||||
a {
|
|
||||||
color: var(--primary);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons (Future Proofing) */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border: none;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: linear-gradient(135deg, var(--primary), hsl(var(--primary-h), 90%, 45%));
|
|
||||||
color: #000;
|
|
||||||
/* Contrast text on Cyan */
|
|
||||||
box-shadow: 0 4px 6px -1px hsla(var(--primary-h), var(--primary-s), 50%, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
box-shadow: 0 6px 8px -1px hsla(var(--primary-h), var(--primary-s), 50%, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Forms & Inputs */
|
|
||||||
input[type="text"],
|
|
||||||
input[type="email"],
|
|
||||||
input[type="password"],
|
|
||||||
textarea,
|
|
||||||
select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background-color: rgba(15, 23, 42, 0.5);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-main);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus,
|
|
||||||
textarea:focus,
|
|
||||||
select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 0 0 2px hsla(var(--primary-h), var(--primary-s), 50%, 0.2);
|
|
||||||
background-color: rgba(15, 23, 42, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tables */
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 1rem;
|
|
||||||
background-color: rgba(15, 23, 42, 0.5);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 1rem;
|
|
||||||
border-bottom: 1px solid #1e293b;
|
|
||||||
/* Fallback or specific border */
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:hover td {
|
|
||||||
background-color: rgba(255, 255, 255, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
footer {
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
background: var(--bg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utilities */
|
|
||||||
.text-gradient {
|
|
||||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animations & Micro-Interactions */
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Entry Animations */
|
|
||||||
.fade-in {
|
|
||||||
animation: fadeIn 0.4s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stagger animations for children using nth-child */
|
|
||||||
main>* {
|
|
||||||
opacity: 0;
|
|
||||||
/* Initially hidden */
|
|
||||||
animation: slideUp 0.5s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
main>*:nth-child(1) {
|
|
||||||
animation-delay: 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
main>*:nth-child(2) {
|
|
||||||
animation-delay: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
main>*:nth-child(3) {
|
|
||||||
animation-delay: 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
main>*:nth-child(4) {
|
|
||||||
animation-delay: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dynamic Background */
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 15% 50%, hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.08), transparent 25%),
|
|
||||||
radial-gradient(circle at 85% 30%, hsla(var(--secondary-h), var(--secondary-s), var(--secondary-l), 0.08), transparent 25%);
|
|
||||||
z-index: -1;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Link Interactions */
|
|
||||||
a {
|
|
||||||
position: relative;
|
|
||||||
transition: color 0.2s ease, opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
header nav a::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: -4px;
|
|
||||||
left: 0;
|
|
||||||
width: 0%;
|
|
||||||
height: 2px;
|
|
||||||
background: var(--primary);
|
|
||||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
header nav a:hover::after {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Accessibility: Reduced Motion */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Responsiveness */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
:root {
|
|
||||||
--header-height: 3.5rem;
|
|
||||||
/* Compact header on mobile */
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-size: 14px;
|
|
||||||
/* Slightly smaller base font */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Layout Adjustments */
|
|
||||||
header {
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
header nav a {
|
|
||||||
margin-left: 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
padding: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Typography Scaling */
|
|
||||||
h1 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card Adjustments */
|
|
||||||
.card {
|
|
||||||
padding: 1.25rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
/* Slightly smaller radius */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stack flex containers if needed (general util) */
|
|
||||||
.flex-col-mobile {
|
|
||||||
flex-direction: column !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Touch Targets */
|
|
||||||
.btn,
|
|
||||||
a,
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
min-height: 44px;
|
|
||||||
/* Compliance with touch target guidelines */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Horizontal scroll for wide tables */
|
|
||||||
.table-container {
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
margin-left: -1rem;
|
|
||||||
margin-right: -1rem;
|
|
||||||
padding-left: 1rem;
|
|
||||||
padding-right: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* Dashboard Layout */
|
|
||||||
.dashboard-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card h3 {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card .stat-value {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-main);
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-main {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 2fr 1fr;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel.control-panel {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header h2 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Activity Feed */
|
|
||||||
.activity-feed {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item .time {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item.info .message { color: var(--text-main); }
|
|
||||||
.activity-item.success .message { color: hsl(150, 60%, 45%); }
|
|
||||||
.activity-item.warning .message { color: hsl(35, 90%, 60%); }
|
|
||||||
.activity-item.error .message { color: hsl(0, 80%, 60%); }
|
|
||||||
|
|
||||||
.badge.live {
|
|
||||||
background: hsla(0, 100%, 50%, 0.2);
|
|
||||||
color: hsl(0, 100%, 60%);
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% { opacity: 1; }
|
|
||||||
50% { opacity: 0.5; }
|
|
||||||
100% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mock Chart */
|
|
||||||
.mock-chart-container {
|
|
||||||
height: 200px;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 4px;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mock-chart-bar {
|
|
||||||
flex: 1;
|
|
||||||
background: var(--primary);
|
|
||||||
opacity: 0.5;
|
|
||||||
border-radius: 2px 2px 0 0;
|
|
||||||
transition: height 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mock-chart-bar:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics-legend {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Dashboard */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.dashboard-grid {
|
|
||||||
grid-template-columns: 1fr 1fr; /* 2 columns on tablet/mobile */
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-main {
|
|
||||||
grid-template-columns: 1fr; /* Stack panels */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
|
||||||
import { router } from "./router";
|
|
||||||
|
|
||||||
describe("Web Router", () => {
|
|
||||||
it("should return home page on /", async () => {
|
|
||||||
const req = new Request("http://localhost/");
|
|
||||||
const res = await router(req);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.headers.get("Content-Type")).toBe("text/html");
|
|
||||||
const text = await res.text();
|
|
||||||
expect(text).toContain("Aurora Web");
|
|
||||||
expect(text).toContain("Uptime:");
|
|
||||||
expect(text).toContain('id="uptime-display"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return dashboard page on /dashboard", async () => {
|
|
||||||
const req = new Request("http://localhost/dashboard");
|
|
||||||
const res = await router(req);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(await res.text()).toContain("Live Activity");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return health check on /health", async () => {
|
|
||||||
const req = new Request("http://localhost/health");
|
|
||||||
const res = await router(req);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.headers.get("Content-Type")).toBe("application/json");
|
|
||||||
const data = await res.json();
|
|
||||||
expect(data).toHaveProperty("status", "ok");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should block path traversal", async () => {
|
|
||||||
// Attempts to go up two directories to reach the project root or src
|
|
||||||
const req = new Request("http://localhost/public/../../package.json");
|
|
||||||
const res = await router(req);
|
|
||||||
// Should be 403 Forbidden or 404 Not Found (our logical change makes it 403)
|
|
||||||
expect([403, 404]).toContain(res.status);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should serve existing static file", async () => {
|
|
||||||
// We know style.css exists in src/web/public
|
|
||||||
const req = new Request("http://localhost/public/style.css");
|
|
||||||
const res = await router(req);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
if (res.status === 200) {
|
|
||||||
const text = await res.text();
|
|
||||||
expect(text).toContain("body");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not serve static files on non-GET methods", async () => {
|
|
||||||
const req = new Request("http://localhost/public/style.css", { method: "POST" });
|
|
||||||
const res = await router(req);
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return 404 for unknown routes", async () => {
|
|
||||||
const req = new Request("http://localhost/unknown");
|
|
||||||
const res = await router(req);
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { homeRoute } from "./routes/home";
|
|
||||||
import { healthRoute } from "./routes/health";
|
|
||||||
import { dashboardRoute } from "./routes/dashboard";
|
|
||||||
import { file } from "bun";
|
|
||||||
import { join, resolve } from "path";
|
|
||||||
|
|
||||||
export async function router(request: Request): Promise<Response> {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const method = request.method;
|
|
||||||
|
|
||||||
// Resolve the absolute path to the public directory
|
|
||||||
const publicDir = resolve(import.meta.dir, "public");
|
|
||||||
|
|
||||||
if (method === "GET") {
|
|
||||||
// Handle Static Files
|
|
||||||
// We handle requests starting with /public/ OR containing an extension (like /style.css)
|
|
||||||
if (url.pathname.startsWith("/public/") || url.pathname.includes(".")) {
|
|
||||||
// Normalize path: remove /public prefix if present so that
|
|
||||||
// /public/style.css and /style.css both map to .../public/style.css
|
|
||||||
const relativePath = url.pathname.replace(/^\/public/, "");
|
|
||||||
|
|
||||||
// Resolve full path
|
|
||||||
const normalizedRelative = relativePath.startsWith("/") ? "." + relativePath : relativePath;
|
|
||||||
const requestedPath = resolve(publicDir, normalizedRelative);
|
|
||||||
|
|
||||||
// Security Check: Block Path Traversal
|
|
||||||
if (requestedPath.startsWith(publicDir)) {
|
|
||||||
const staticFile = file(requestedPath);
|
|
||||||
if (await staticFile.exists()) {
|
|
||||||
return new Response(staticFile);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return new Response("Forbidden", { status: 403 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
||||||
return homeRoute();
|
|
||||||
}
|
|
||||||
if (url.pathname === "/health") {
|
|
||||||
return healthRoute();
|
|
||||||
}
|
|
||||||
if (url.pathname === "/dashboard") {
|
|
||||||
return dashboardRoute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response("Not Found", { status: 404 });
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { BaseLayout } from "../views/layout";
|
|
||||||
|
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
|
||||||
import { getRecentLogs } from "@/lib/logger";
|
|
||||||
|
|
||||||
export function dashboardRoute(): Response {
|
|
||||||
|
|
||||||
// Gather real data
|
|
||||||
const guildCount = AuroraClient.guilds.cache.size;
|
|
||||||
const userCount = AuroraClient.guilds.cache.reduce((acc, guild) => acc + guild.memberCount, 0);
|
|
||||||
const commandCount = AuroraClient.commands.size;
|
|
||||||
const ping = AuroraClient.ws.ping;
|
|
||||||
|
|
||||||
// Real system metrics
|
|
||||||
const memoryUsage = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2);
|
|
||||||
const uptimeSeconds = process.uptime();
|
|
||||||
const uptime = new Date(uptimeSeconds * 1000).toISOString().substr(11, 8); // HH:MM:SS
|
|
||||||
|
|
||||||
// Real activity logs
|
|
||||||
const activityLogs = getRecentLogs();
|
|
||||||
|
|
||||||
const content = `
|
|
||||||
<div class="dashboard-grid">
|
|
||||||
<!-- Top Stats Row -->
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3>Servers</h3>
|
|
||||||
<div class="stat-value">${guildCount}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3>Users</h3>
|
|
||||||
<div class="stat-value">${userCount}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3>Commands</h3>
|
|
||||||
<div class="stat-value">${commandCount}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3>Ping</h3>
|
|
||||||
<div class="stat-value">${ping < 0 ? "?" : ping}ms</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
|
||||||
<div class="dashboard-main">
|
|
||||||
<div class="panel activity-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>Live Activity</h2>
|
|
||||||
<span class="badge live">LIVE</span>
|
|
||||||
</div>
|
|
||||||
<ul class="activity-feed">
|
|
||||||
${activityLogs.length > 0 ? activityLogs.map(log => `
|
|
||||||
<li class="activity-item ${log.type}">
|
|
||||||
<span class="time">${log.time}</span>
|
|
||||||
<span class="message">${log.message}</span>
|
|
||||||
</li>
|
|
||||||
`).join('') : `
|
|
||||||
<li class="activity-item info"><span class="time">--:--:--</span> <span class="message">No recent activity.</span></li>
|
|
||||||
`}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel metrics-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>System Health</h2>
|
|
||||||
</div>
|
|
||||||
<div class="metrics-grid">
|
|
||||||
<div class="metric-item">
|
|
||||||
<span class="metric-label">Uptime</span>
|
|
||||||
<span class="metric-value">${uptime}</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<span class="metric-label">Memory (Heap)</span>
|
|
||||||
<span class="metric-value">${memoryUsage} MB</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<span class="metric-label">Node Version</span>
|
|
||||||
<span class="metric-value">${process.version}</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<span class="metric-label">Platform</span>
|
|
||||||
<span class="metric-value">${process.platform}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Control Panel -->
|
|
||||||
<div class="panel control-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>Quick Actions</h2>
|
|
||||||
</div>
|
|
||||||
<div class="action-buttons">
|
|
||||||
<button class="btn btn-secondary" disabled>Clear Cache</button>
|
|
||||||
<button class="btn btn-secondary" disabled>Reload Commands</button>
|
|
||||||
<button class="btn btn-danger" disabled>Restart Bot</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const html = BaseLayout({ title: "Dashboard", content });
|
|
||||||
|
|
||||||
return new Response(html, {
|
|
||||||
headers: { "Content-Type": "text/html" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export function healthRoute(): Response {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
status: "ok",
|
|
||||||
uptime: process.uptime(),
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
}), {
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { BaseLayout } from "../views/layout";
|
|
||||||
|
|
||||||
export function homeRoute(): Response {
|
|
||||||
const content = `
|
|
||||||
<div class="card">
|
|
||||||
<h2>Welcome</h2>
|
|
||||||
<p>The Aurora web server is up and running!</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const html = BaseLayout({ title: "Home", content });
|
|
||||||
|
|
||||||
return new Response(html, {
|
|
||||||
headers: { "Content-Type": "text/html" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { env } from "@/lib/env";
|
|
||||||
import { router } from "./router";
|
|
||||||
import type { Server } from "bun";
|
|
||||||
|
|
||||||
export class WebServer {
|
|
||||||
private static server: Server<unknown> | null = null;
|
|
||||||
private static heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
public static start(port?: number) {
|
|
||||||
this.server = Bun.serve({
|
|
||||||
port: port ?? (typeof env.PORT === "string" ? parseInt(env.PORT) : 3000),
|
|
||||||
fetch: (req, server) => {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
if (url.pathname === "/ws") {
|
|
||||||
// Upgrade the request to a WebSocket
|
|
||||||
// We pass dummy data for now
|
|
||||||
if (server.upgrade(req, { data: undefined })) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return new Response("WebSocket upgrade failed", { status: 500 });
|
|
||||||
}
|
|
||||||
return router(req);
|
|
||||||
},
|
|
||||||
websocket: {
|
|
||||||
open(ws) {
|
|
||||||
// console.log("ws: client connected");
|
|
||||||
ws.subscribe("status-updates");
|
|
||||||
ws.send(JSON.stringify({ type: "WELCOME", message: "Connected to Aurora WebSocket" }));
|
|
||||||
},
|
|
||||||
message(ws, message) {
|
|
||||||
// Handle incoming messages if needed
|
|
||||||
},
|
|
||||||
close(ws) {
|
|
||||||
// console.log("ws: client disconnected");
|
|
||||||
ws.unsubscribe("status-updates");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`🌐 Web server listening on http://localhost:${this.server.port}`);
|
|
||||||
|
|
||||||
// Start a heartbeat loop
|
|
||||||
this.heartbeatInterval = setInterval(() => {
|
|
||||||
if (this.server) {
|
|
||||||
const uptime = process.uptime();
|
|
||||||
this.server.publish("status-updates", JSON.stringify({
|
|
||||||
type: "HEARTBEAT",
|
|
||||||
data: {
|
|
||||||
uptime,
|
|
||||||
timestamp: Date.now()
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static stop() {
|
|
||||||
if (this.heartbeatInterval) {
|
|
||||||
clearInterval(this.heartbeatInterval);
|
|
||||||
this.heartbeatInterval = null;
|
|
||||||
}
|
|
||||||
if (this.server) {
|
|
||||||
this.server.stop();
|
|
||||||
console.log("🛑 Web server stopped");
|
|
||||||
this.server = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get port(): number | undefined {
|
|
||||||
return this.server?.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static broadcastLog(type: string, message: string) {
|
|
||||||
if (this.server) {
|
|
||||||
this.server.publish("status-updates", JSON.stringify({
|
|
||||||
type: "LOG",
|
|
||||||
data: {
|
|
||||||
timestamp: new Date().toLocaleTimeString(),
|
|
||||||
type,
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
22
src/web/src/App.tsx
Normal file
22
src/web/src/App.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
|
import { DashboardLayout } from "./layouts/DashboardLayout";
|
||||||
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
|
import { Activity } from "./pages/Activity";
|
||||||
|
import { Settings } from "./pages/Settings";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DashboardLayout />}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="activity" element={<Activity />} />
|
||||||
|
<Route path="settings" element={<Settings />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
96
src/web/src/components/AppSidebar.tsx
Normal file
96
src/web/src/components/AppSidebar.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
|
||||||
|
import { LayoutDashboard, Settings, Activity, Server, Zap } from "lucide-react";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarRail,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
|
||||||
|
// Menu items.
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
title: "Dashboard",
|
||||||
|
url: "/",
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Activity",
|
||||||
|
url: "/activity",
|
||||||
|
icon: Activity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
url: "/settings",
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppSidebar() {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar>
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton size="lg" asChild>
|
||||||
|
<Link to="/">
|
||||||
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
|
<Zap className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
|
<span className="font-semibold">Aurora</span>
|
||||||
|
<span className="">v1.0.0</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Application</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton asChild isActive={location.pathname === item.url}>
|
||||||
|
<Link to={item.url}>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton size="lg">
|
||||||
|
<div className="bg-muted flex aspect-square size-8 items-center justify-center rounded-lg">
|
||||||
|
<span className="text-xs font-bold">U</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
|
<span className="font-semibold">User</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Admin</span>
|
||||||
|
</div>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarFooter>
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/web/src/components/ui/button.tsx
Normal file
62
src/web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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 hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white 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 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",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
92
src/web/src/components/ui/card.tsx
Normal file
92
src/web/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
21
src/web/src/components/ui/input.tsx
Normal file
21
src/web/src/components/ui/input.tsx
Normal 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 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 }
|
||||||
26
src/web/src/components/ui/separator.tsx
Normal file
26
src/web/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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 }
|
||||||
139
src/web/src/components/ui/sheet.tsx
Normal file
139
src/web/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
726
src/web/src/components/ui/sidebar.tsx
Normal file
726
src/web/src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
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 min-h-svh 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,
|
||||||
|
useSidebar,
|
||||||
|
}
|
||||||
13
src/web/src/components/ui/skeleton.tsx
Normal file
13
src/web/src/components/ui/skeleton.tsx
Normal 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 }
|
||||||
59
src/web/src/components/ui/tooltip.tsx
Normal file
59
src/web/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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-foreground text-background 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-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
26
src/web/src/frontend.tsx
Normal file
26
src/web/src/frontend.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* This file is the entry point for the React app, it sets up the root
|
||||||
|
* element and renders the App component to the DOM.
|
||||||
|
*
|
||||||
|
* It is included in `src/index.html`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
const elem = document.getElementById("root")!;
|
||||||
|
const app = (
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||||
|
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||||
|
root.render(app);
|
||||||
|
} else {
|
||||||
|
// The hot module reloading API is not available in production.
|
||||||
|
createRoot(elem).render(app);
|
||||||
|
}
|
||||||
19
src/web/src/hooks/use-mobile.ts
Normal file
19
src/web/src/hooks/use-mobile.ts
Normal 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
|
||||||
|
}
|
||||||
1
src/web/src/index.css
Normal file
1
src/web/src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "../styles/globals.css";
|
||||||
15
src/web/src/index.html
Normal file
15
src/web/src/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Aurora</title>
|
||||||
|
<script type="module" src="./frontend.tsx" async></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
18
src/web/src/index.ts
Normal file
18
src/web/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Web server entry point.
|
||||||
|
*
|
||||||
|
* This file can be run directly for standalone development:
|
||||||
|
* bun --hot src/index.ts
|
||||||
|
*
|
||||||
|
* Or the server can be started in-process by importing from ./server.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createWebServer } from "./server";
|
||||||
|
|
||||||
|
// Auto-start when run directly
|
||||||
|
const instance = await createWebServer({
|
||||||
|
port: Number(process.env.WEB_PORT) || 3000,
|
||||||
|
hostname: process.env.WEB_HOST || "localhost",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🌐 Web server is running at ${instance.url}`);
|
||||||
28
src/web/src/layouts/DashboardLayout.tsx
Normal file
28
src/web/src/layouts/DashboardLayout.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { AppSidebar } from "../components/AppSidebar";
|
||||||
|
import { SidebarProvider, SidebarInset, SidebarTrigger } from "../components/ui/sidebar";
|
||||||
|
import { Separator } from "../components/ui/separator";
|
||||||
|
|
||||||
|
export function DashboardLayout() {
|
||||||
|
return (
|
||||||
|
<SidebarProvider>
|
||||||
|
<AppSidebar />
|
||||||
|
<SidebarInset>
|
||||||
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||||
|
<SidebarTrigger className="-ml-1" />
|
||||||
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
|
<div className="flex items-center gap-2 px-4">
|
||||||
|
{/* Breadcrumbs could go here */}
|
||||||
|
<h1 className="text-lg font-semibold">Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||||
|
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min p-4">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/web/src/lib/utils.ts
Normal file
6
src/web/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
12
src/web/src/pages/Activity.tsx
Normal file
12
src/web/src/pages/Activity.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
export function Activity() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Activity</h2>
|
||||||
|
<p className="text-muted-foreground">Recent bot activity logs.</p>
|
||||||
|
<div className="mt-6 rounded-xl border border-dashed p-8 text-center text-muted-foreground">
|
||||||
|
Activity feed coming soon...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/web/src/pages/Dashboard.tsx
Normal file
111
src/web/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Activity, Server, Users, Zap } from "lucide-react";
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
||||||
|
<p className="text-muted-foreground">Overview of your bot's activity and performance.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{/* Metric Cards */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Servers</CardTitle>
|
||||||
|
<Server className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">12</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+2 from last month</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Active Users</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">1,234</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+10% from last month</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Commands Run</CardTitle>
|
||||||
|
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">12,345</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+5% from last month</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Avg Ping</CardTitle>
|
||||||
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">24ms</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+2ms from last hour</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
|
<Card className="col-span-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Activity Overview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[200px] w-full bg-muted/20 flex items-center justify-center border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
||||||
|
Chart Placeholder
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="col-span-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Events</CardTitle>
|
||||||
|
<CardDescription>Latest system and bot events.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-emerald-500 mr-2" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">New guild joined</p>
|
||||||
|
<p className="text-sm text-muted-foreground">2 minutes ago</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-destructive mr-2" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">Error in verify command</p>
|
||||||
|
<p className="text-sm text-muted-foreground">15 minutes ago</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-500 mr-2" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">Bot restarted</p>
|
||||||
|
<p className="text-sm text-muted-foreground">1 hour ago</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/web/src/pages/Settings.tsx
Normal file
12
src/web/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
export function Settings() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Settings</h2>
|
||||||
|
<p className="text-muted-foreground">Manage bot configuration.</p>
|
||||||
|
<div className="mt-6 rounded-xl border border-dashed p-8 text-center text-muted-foreground">
|
||||||
|
Settings panel coming soon...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/web/src/server.ts
Normal file
85
src/web/src/server.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Web server factory module.
|
||||||
|
* Exports a function to create and start the web server.
|
||||||
|
* This allows the server to be started in-process from the main application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { serve } from "bun";
|
||||||
|
|
||||||
|
export interface WebServerConfig {
|
||||||
|
port?: number;
|
||||||
|
hostname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebServerInstance {
|
||||||
|
server: ReturnType<typeof serve>;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and starts the web server.
|
||||||
|
*
|
||||||
|
* IMPORTANT: This function must be called from within the web project directory
|
||||||
|
* or the bundler won't resolve paths correctly. Use `startWebServerFromRoot`
|
||||||
|
* if calling from the main application.
|
||||||
|
*/
|
||||||
|
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
|
||||||
|
const { port = 3000, hostname = "localhost" } = config;
|
||||||
|
|
||||||
|
// Dynamic import of the HTML to ensure bundler context is correct
|
||||||
|
const index = await import("./index.html");
|
||||||
|
|
||||||
|
const server = serve({
|
||||||
|
port,
|
||||||
|
hostname,
|
||||||
|
routes: {
|
||||||
|
// Serve index.html for all unmatched routes (SPA catch-all)
|
||||||
|
"/*": index.default,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"/api/health": () => Response.json({ status: "ok", timestamp: Date.now() }),
|
||||||
|
},
|
||||||
|
|
||||||
|
development: process.env.NODE_ENV !== "production" && {
|
||||||
|
hmr: true,
|
||||||
|
console: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `http://${hostname}:${port}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
url,
|
||||||
|
stop: async () => {
|
||||||
|
server.stop(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the web server from the main application root.
|
||||||
|
* Handles the working directory context switch needed for bundler resolution.
|
||||||
|
*/
|
||||||
|
export async function startWebServerFromRoot(
|
||||||
|
webProjectPath: string,
|
||||||
|
config: WebServerConfig = {}
|
||||||
|
): Promise<WebServerInstance> {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Change to web project directory for correct bundler resolution
|
||||||
|
process.chdir(webProjectPath);
|
||||||
|
|
||||||
|
const instance = await createWebServer(config);
|
||||||
|
|
||||||
|
console.log(`🌐 Web server running at ${instance.url}`);
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
} finally {
|
||||||
|
// Restore original working directory
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/web/styles/globals.css
Normal file
120
src/web/styles/globals.css
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/web/tsconfig.json
Normal file
36
src/web/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
},
|
||||||
|
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
|
||||||
import { formatUptime } from "./format";
|
|
||||||
|
|
||||||
describe("formatUptime", () => {
|
|
||||||
it("formats seconds correctly", () => {
|
|
||||||
expect(formatUptime(45)).toBe("45s");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats minutes and seconds", () => {
|
|
||||||
expect(formatUptime(65)).toBe("1m 5s");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats hours, minutes, and seconds", () => {
|
|
||||||
expect(formatUptime(3665)).toBe("1h 1m 5s");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats days correctly", () => {
|
|
||||||
expect(formatUptime(90061)).toBe("1d 1h 1m 1s");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles zero", () => {
|
|
||||||
expect(formatUptime(0)).toBe("0s");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* Formats a duration in seconds into a human-readable string.
|
|
||||||
* Example: 3665 -> "1h 1m 5s"
|
|
||||||
*/
|
|
||||||
export function formatUptime(seconds: number): string {
|
|
||||||
if (seconds < 0) return "0s";
|
|
||||||
|
|
||||||
const days = Math.floor(seconds / (3600 * 24));
|
|
||||||
const hours = Math.floor((seconds % (3600 * 24)) / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
|
|
||||||
const parts = [];
|
|
||||||
if (days > 0) parts.push(`${days}d`);
|
|
||||||
if (hours > 0) parts.push(`${hours}h`);
|
|
||||||
if (minutes > 0) parts.push(`${minutes}m`);
|
|
||||||
parts.push(`${secs}s`);
|
|
||||||
|
|
||||||
return parts.join(" ");
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
|
|
||||||
import { describe, expect, it } from "bun:test";
|
|
||||||
import { escapeHtml } from "./html";
|
|
||||||
|
|
||||||
describe("HTML Utils", () => {
|
|
||||||
it("should escape special characters", () => {
|
|
||||||
const unsafe = '<script>alert("xss")</script>';
|
|
||||||
const safe = escapeHtml(unsafe);
|
|
||||||
expect(safe).toBe("<script>alert("xss")</script>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle mixed content", () => {
|
|
||||||
const unsafe = 'Hello & "World"';
|
|
||||||
const safe = escapeHtml(unsafe);
|
|
||||||
expect(safe).toBe("Hello & "World"");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
|
|
||||||
/**
|
|
||||||
* Escapes unsafe characters in a string to prevent XSS.
|
|
||||||
* @param unsafe - The raw string to escape.
|
|
||||||
* @returns The escaped string safe for HTML insertion.
|
|
||||||
*/
|
|
||||||
export function escapeHtml(unsafe: string): string {
|
|
||||||
return unsafe
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { escapeHtml } from "../utils/html";
|
|
||||||
import { formatUptime } from "../utils/format";
|
|
||||||
|
|
||||||
interface LayoutProps {
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BaseLayout({ title, content }: LayoutProps): string {
|
|
||||||
const safeTitle = escapeHtml(title);
|
|
||||||
|
|
||||||
// Calculate uptime for the footer
|
|
||||||
const uptimeSeconds = process.uptime();
|
|
||||||
const startTimestamp = Date.now() - (uptimeSeconds * 1000);
|
|
||||||
const initialUptimeString = formatUptime(uptimeSeconds);
|
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${safeTitle} | Aurora</title>
|
|
||||||
<link rel="stylesheet" href="/style.css">
|
|
||||||
<meta name="description" content="Aurora Bot Web Interface">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1>Aurora Web</h1>
|
|
||||||
<nav>
|
|
||||||
<a href="/">Home</a>
|
|
||||||
<a href="/dashboard">Dashboard</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
${content}
|
|
||||||
</main>
|
|
||||||
<footer>
|
|
||||||
<div class="footer-content">
|
|
||||||
<p>© ${new Date().getFullYear()} Aurora Bot</p>
|
|
||||||
<div class="footer-status">
|
|
||||||
<span class="status-indicator online">●</span>
|
|
||||||
<span>System Operational</span>
|
|
||||||
<span class="separator">|</span>
|
|
||||||
<span>Uptime: <span id="uptime-display" data-start-timestamp="${Math.floor(startTimestamp)}">${initialUptimeString}</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
<script src="/script.js" defer></script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { describe, expect, it, afterAll, beforeAll } from "bun:test";
|
|
||||||
import { WebServer } from "./server";
|
|
||||||
|
|
||||||
describe("WebSocket Server", () => {
|
|
||||||
// Start server on a random port
|
|
||||||
const port = 0;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
WebServer.start(port);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
WebServer.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept websocket connection and send welcome message", async () => {
|
|
||||||
const port = WebServer.port;
|
|
||||||
expect(port).toBeDefined();
|
|
||||||
|
|
||||||
const ws = new WebSocket(`ws://localhost:${port}/ws`);
|
|
||||||
|
|
||||||
const messagePromise = new Promise<any>((resolve) => {
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
resolve(JSON.parse(event.data as string));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const msg = await messagePromise;
|
|
||||||
expect(msg.type).toBe("WELCOME");
|
|
||||||
expect(msg.message).toContain("Connected");
|
|
||||||
|
|
||||||
ws.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject non-ws upgrade requests on /ws endpoint via http", async () => {
|
|
||||||
const port = WebServer.port;
|
|
||||||
// Just a normal fetch to /ws should fail with 426 Upgrade Required usually,
|
|
||||||
// but our implementation returns "WebSocket upgrade failed" 500 or undefined -> 101 Switching Protocols if valid.
|
|
||||||
// If we send a normal GET request to /ws without Upgrade headers, server.upgrade(req) returns false.
|
|
||||||
// So it returns status 500 "WebSocket upgrade failed" based on our code.
|
|
||||||
|
|
||||||
const res = await fetch(`http://localhost:${port}/ws`);
|
|
||||||
expect(res.status).toBe(500);
|
|
||||||
expect(await res.text()).toBe("WebSocket upgrade failed");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
|
|
||||||
# 2026-01-07-replace-mock-dashboard-data.md: Replace Mock Dashboard Data with Live Telemetry
|
|
||||||
|
|
||||||
**Status:** Done
|
|
||||||
**Created:** 2026-01-07
|
|
||||||
**Tags:** dashboard, telemetry, logging, database
|
|
||||||
|
|
||||||
## 1. Context & User Story
|
|
||||||
* **As a:** Bot Administrator
|
|
||||||
* **I want to:** see actual system logs, real-time resource usage, and accurate database statistics on the web dashboard
|
|
||||||
* **So that:** I can monitor the true health and activity of the Aurora application without checking the terminal or database manually.
|
|
||||||
|
|
||||||
## 2. Technical Requirements
|
|
||||||
### Data Model Changes
|
|
||||||
- [ ] No strict database schema changes required, but may need a cohesive `LogService` or in-memory buffer to store recent "Activity" events for the dashboard history.
|
|
||||||
|
|
||||||
### API / Interface
|
|
||||||
- **Dashboard Route (`src/web/routes/dashboard.ts`):**
|
|
||||||
- [x] Replace `mockedActivity` array with a fetch from a real log buffer/source.
|
|
||||||
- [x] Replace `userCount` approximation with a precise count from `UserService` or `AuroraClient`.
|
|
||||||
- [x] Replace "System Metrics" mock bars with real values (RAM usage, Uptime, CPU load if possible).
|
|
||||||
- **Log Source:**
|
|
||||||
- [x] Implement a mechanism (e.g., specific `Logger` transport or `WebServer` static buffer) to capture the last ~50 distinct application events (commands, errors, warnings) for display.
|
|
||||||
- [ ] (Optional) If "Docker Compose Logs" are strictly required, implement a file reader for the standard output log file if accessible, otherwise rely on internal application logging.
|
|
||||||
|
|
||||||
### Real Data Integration
|
|
||||||
- **Activity Feed:** Must show actual commands executed, system errors, and startup events.
|
|
||||||
- **Top Stats:** Ensure `Servers`, `Users`, `Commands`, and `Ping` come from the live `AuroraClient` instance.
|
|
||||||
- **Metrics:** Display `process.memoryUsage().heapUsed` converted to MB. Display `process.uptime()`.
|
|
||||||
|
|
||||||
## 3. Constraints & Validations (CRITICAL)
|
|
||||||
- **Performance:** Fetching logs or stats must not block the event loop. Avoid heavy DB queries on every dashboard refresh; cache stats if necessary (e.g., via `setInterval` in background).
|
|
||||||
- **Security:** Do not expose sensitive data (tokens, raw SQL) in the activity feed.
|
|
||||||
- **Fallbacks:** If data is unavailable (e.g., client not ready), show "Loading..." or a neutral placeholder, not fake data.
|
|
||||||
|
|
||||||
## 4. Acceptance Criteria
|
|
||||||
1. [x] The "Activity Feed" on the dashboard displays real, recent events that occurred in the application (e.g., "Bot started", "Command /ping executed").
|
|
||||||
2. [x] The "System Metrics" section displays a visual representation (or text) of **actual** memory usage and uptime.
|
|
||||||
3. [x] The hardcoded `mockedActivity` array is removed from `dashboard.ts`.
|
|
||||||
4. [x] Refreshing the dashboard page updates the metrics and feed with the latest data.
|
|
||||||
|
|
||||||
## 5. Implementation Plan
|
|
||||||
- [x] Step 1: Create a simple in-memory `LogBuffer` in `src/lib/logger.ts` (or similar) to keep the last 50 logs.
|
|
||||||
- [x] Step 2: Hook this buffer into the existing logging system (or add manual pushes in `command.handler.ts` etc).
|
|
||||||
- [x] Step 3: Implement `getSystemMetrics()` helper to return formatted RAM/CPU data.
|
|
||||||
- [x] Step 4: Update `src/web/routes/dashboard.ts` to import the log buffer and metrics helper.
|
|
||||||
- [x] Step 5: Replace the HTML template variables with these real data sources.
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
- **Log Buffer**: Added a 50-item rolling buffer in `src/lib/logger.ts` exposing `getRecentLogs()`.
|
|
||||||
- **Dashboard Update**: `src/web/routes/dashboard.ts` now uses `AuroraClient` stats and `process` metrics (Uptime, Memory) directly.
|
|
||||||
- **Tests**: Added `src/lib/logger.test.ts` to verify buffer logic.
|
|
||||||
Reference in New Issue
Block a user