21 Commits

Author SHA1 Message Date
syntaxbullet
53a2f1ff0c chore: combine processes 2026-01-08 15:13:09 +01:00
syntaxbullet
dc15212ecf web: mock dashboard 2026-01-08 14:49:59 +01:00
syntaxbullet
99e847175e chore: remove frontend boilerplate 2026-01-08 14:26:16 +01:00
syntaxbullet
b2c7fa6e83 feat: improvements to update command 2026-01-08 14:13:24 +01:00
syntaxbullet
9e7f18787b feat: improvements to web dashboard 2026-01-08 13:56:25 +01:00
47507dd65a Merge pull request 'added react app' (#4) from HotPlate/discord-rpg-concept:reactApp into main
Reviewed-on: #4
2026-01-08 11:51:18 +00:00
Vraj Ved
e6f94c3e71 added react app 2026-01-08 17:15:28 +05:30
syntaxbullet
66af870aa9 fix: make dashboard locally accessible only 2026-01-07 14:33:19 +01:00
syntaxbullet
8047bce755 feat: add bot action controls and real-time vital statistics to the web dashboard 2026-01-07 14:26:37 +01:00
syntaxbullet
9804456257 docs: Remove completed and draft feature tickets from the tickets directory. 2026-01-07 13:49:04 +01:00
syntaxbullet
259b8d6875 feat: replace mock dashboard data with live telemetry 2026-01-07 13:47:02 +01:00
syntaxbullet
a2cb684b71 Merge branch 'feat/web-interface-expansion-mockup' into main 2026-01-07 13:39:41 +01:00
syntaxbullet
9c2098bc46 fix(test): use dynamic port for websocket tests 2026-01-07 13:37:21 +01:00
syntaxbullet
618d973863 feat: expansion of web dashboard with live activity feed and metrics 2026-01-07 13:34:29 +01:00
syntaxbullet
63f55b6dfd feat: implement dashboard mockup and route 2026-01-07 13:29:06 +01:00
syntaxbullet
ac4025e179 feat: implement websocket realtime data streaming 2026-01-07 13:25:41 +01:00
syntaxbullet
ff23f22337 feat: move status to footer and clean up home page 2026-01-07 13:21:36 +01:00
syntaxbullet
292991c605 feat: responsive mobile layout and touch optimizations 2026-01-07 13:08:02 +01:00
syntaxbullet
4640cd11a7 feat: ux enhancements (animations, dynamic backgrounds, micro-interactions) 2026-01-07 13:05:42 +01:00
syntaxbullet
43a003f641 feat: visual design system overhaul (HSL palette, fonts, components) 2026-01-07 13:04:40 +01:00
syntaxbullet
6f4426e49d feat: save progress on web server foundation and add new tickets 2026-01-07 13:02:36 +01:00
59 changed files with 2993 additions and 582 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View 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

View File

@@ -9,39 +9,74 @@ 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")
.addSubcommand(sub =>
sub.setName("check")
.setDescription("Check for and apply available updates")
.addBooleanOption(option => .addBooleanOption(option =>
option.setName("force") option.setName("force")
.setDescription("Force update even if checks fail (not recommended)") .setDescription("Force update even if no changes detected")
.setRequired(false) .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) => {
const subcommand = interaction.options.getSubcommand();
if (subcommand === "rollback") {
await handleRollback(interaction);
} else {
await handleUpdate(interaction);
}
}
});
async function handleUpdate(interaction: any) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const force = interaction.options.getBoolean("force") || false; const force = interaction.options.getBoolean("force") || false;
try { try {
// 1. Check for updates
await interaction.editReply({ embeds: [getCheckingEmbed()] }); await interaction.editReply({ embeds: [getCheckingEmbed()] });
const updateInfo = await UpdateService.checkForUpdates();
const { hasUpdates, log, branch } = await UpdateService.checkForUpdates(); if (!updateInfo.hasUpdates && !force) {
await interaction.editReply({
if (!hasUpdates && !force) { embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)]
await interaction.editReply({ embeds: [getNoUpdatesEmbed()] }); });
return; return;
} }
const { embeds, components } = getUpdatesAvailableMessage(branch, log, force); // 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 }); const response = await interaction.editReply({ embeds, components });
// 4. Wait for confirmation
try { try {
const confirmation = await response.awaitMessageComponent({ const confirmation = await response.awaitMessageComponent({
filter: (i) => i.user.id === interaction.user.id, filter: (i: any) => i.user.id === interaction.user.id,
componentType: ComponentType.Button, componentType: ComponentType.Button,
time: 30000 time: 30000
}); });
@@ -52,25 +87,29 @@ export const update = createCommand({
components: [] components: []
}); });
// 1. Check what the update requires // 5. Save rollback point
const { needsInstall, needsMigrations } = await UpdateService.checkUpdateRequirements(branch); const previousCommit = await UpdateService.saveRollbackPoint();
// 2. Prepare context BEFORE update // 6. Prepare restart context
await UpdateService.prepareRestartContext({ await UpdateService.prepareRestartContext({
channelId: interaction.channelId, channelId: interaction.channelId,
userId: interaction.user.id, userId: interaction.user.id,
timestamp: Date.now(), timestamp: Date.now(),
runMigrations: needsMigrations, runMigrations: requirements.needsMigrations,
installDependencies: needsInstall installDependencies: requirements.needsRootInstall || requirements.needsWebInstall,
previousCommit: previousCommit.substring(0, 7),
newCommit: updateInfo.latestCommit
}); });
// 3. Update UI to "Restarting" state // 7. Show updating status
await interaction.editReply({ embeds: [getUpdatingEmbed(needsInstall)] }); await interaction.editReply({
embeds: [getUpdatingEmbed(requirements)]
});
// 4. Perform Update (Danger Zone) // 8. Perform update
await UpdateService.performUpdate(branch); await UpdateService.performUpdate(updateInfo.branch);
// 5. Trigger Restart (if we are still alive) // 9. Trigger restart
await UpdateService.triggerRestart(); await UpdateService.triggerRestart();
} else { } else {
@@ -93,7 +132,45 @@ export const update = createCommand({
} catch (error) { } catch (error) {
console.error("Update failed:", error); console.error("Update failed:", error);
await interaction.editReply({ embeds: [getErrorEmbed(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)]
});
}
}

View File

@@ -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);

View File

@@ -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);
} }
} }

View File

@@ -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);

View File

@@ -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);
} }
} }
} }

View File

@@ -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) {

View File

@@ -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);
} }
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -1,39 +0,0 @@
/**
* Centralized logging utility with consistent formatting
*/
export const logger = {
/**
* General information message
*/
info: (message: string, ...args: any[]) => {
console.log(` ${message}`, ...args);
},
/**
* Success message
*/
success: (message: string, ...args: any[]) => {
console.log(`${message}`, ...args);
},
/**
* Warning message
*/
warn: (message: string, ...args: any[]) => {
console.warn(`⚠️ ${message}`, ...args);
},
/**
* Error message
*/
error: (message: string, ...args: any[]) => {
console.error(`${message}`, ...args);
},
/**
* Debug message
*/
debug: (message: string, ...args: any[]) => {
console.log(`🔍 ${message}`, ...args);
},
};

View File

@@ -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));

View File

@@ -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> {

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
[serve.static]
plugins = ["bun-plugin-tailwind"]
env = "BUN_PUBLIC_*"

21
src/web/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "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
View 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"
}
}

View File

@@ -1,68 +0,0 @@
:root {
--bg-color: #0f172a;
--text-color: #f8fafc;
--accent-color: #38bdf8;
--card-bg: #1e293b;
--font-family: system-ui, -apple-system, sans-serif;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-family);
margin: 0;
line-height: 1.5;
display: flex;
flex-direction: column;
min-height: 100vh;
}
header {
background-color: var(--card-bg);
padding: 1rem 2rem;
border-bottom: 1px solid #334155;
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
margin: 0;
font-size: 1.5rem;
color: var(--accent-color);
}
main {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.card {
background-color: var(--card-bg);
border-radius: 0.5rem;
padding: 1.5rem;
margin-bottom: 1rem;
border: 1px solid #334155;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
footer {
text-align: center;
padding: 1rem;
color: #94a3b8;
font-size: 0.875rem;
border-top: 1px solid #334155;
}
a {
color: var(--accent-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

View File

@@ -1,52 +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");
expect(await res.text()).toContain("Aurora Web");
});
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);
});
});

View File

@@ -1,53 +0,0 @@
import { homeRoute } from "./routes/home";
import { healthRoute } from "./routes/health";
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
// We use join with relativePath. If relativePath starts with /, join handles it correctly
// effectively treating it as a segment.
// However, to be extra safe with 'resolve', we ensure we are resolving from publicDir.
// simple join(publicDir, relativePath) is usually enough with 'bun'.
// But we use 'resolve' to handle .. segments correctly.
// We prepend '.' to relativePath to ensure it's treated as relative to publicDir logic
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 {
// If path traversal detected, return 403 or 404.
// 403 indicates we caught them.
return new Response("Forbidden", { status: 403 });
}
}
if (url.pathname === "/" || url.pathname === "/index.html") {
return homeRoute();
}
if (url.pathname === "/health") {
return healthRoute();
}
}
return new Response("Not Found", { status: 404 });
}

View File

@@ -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" },
});
}

View File

@@ -1,20 +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>
<div class="card">
<h3>Status</h3>
<p>System operational.</p>
</div>
`;
const html = BaseLayout({ title: "Home", content });
return new Response(html, {
headers: { "Content-Type": "text/html" },
});
}

View File

@@ -1,24 +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;
public static start() {
this.server = Bun.serve({
port: env.PORT || 3000,
fetch: router,
});
console.log(`🌐 Web server listening on http://localhost:${this.server.port}`);
}
public static stop() {
if (this.server) {
this.server.stop();
console.log("🛑 Web server stopped");
this.server = null;
}
}
}

22
src/web/src/App.tsx Normal file
View 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;

View 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>
);
}

View 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 }

View 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,
}

View File

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

View File

@@ -0,0 +1,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 }

View 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,
}

View 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,
}

View File

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

View File

@@ -0,0 +1,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
View 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);
}

View File

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

1
src/web/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "../styles/globals.css";

15
src/web/src/index.html Normal file
View 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
View 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}`);

View 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
View 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));
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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"]
}

View File

@@ -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("&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;");
});
it("should handle mixed content", () => {
const unsafe = 'Hello & "World"';
const safe = escapeHtml(unsafe);
expect(safe).toBe("Hello &amp; &quot;World&quot;");
});
});

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

View File

@@ -1,34 +0,0 @@
import { escapeHtml } from "../utils/html";
interface LayoutProps {
title: string;
content: string;
}
export function BaseLayout({ title, content }: LayoutProps): string {
const safeTitle = escapeHtml(title);
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">
</head>
<body>
<header>
<h1>Aurora Web</h1>
<nav>
<a href="/">Home</a>
</nav>
</header>
<main>
${content}
</main>
<footer>
<p>&copy; ${new Date().getFullYear()} Aurora Bot</p>
</footer>
</body>
</html>`;
}

View File

@@ -1,59 +0,0 @@
# 2026-01-07-web-server-foundation: Web Server Infrastructure Foundation
**Status:** Done
**Created:** 2026-01-07
**Tags:** infrastructure, web, core
## 1. Context & User Story
* **As a:** Developer
* **I want to:** Establish a lightweight, integrated web server foundation within the existing codebase.
* **So that:** We can serve internal tools (Workbench) or public pages (Leaderboard) with minimal friction, avoiding complex separate build pipelines.
## 2. Technical Requirements
### Architecture
- **Native Bun Server:** Use `Bun.serve()` for high performance.
- **Exposure:** The server port must be exposed in `docker-compose.yml` to be accessible outside the container.
- **Rendering Strategy:** **Server-Side Rendering (SSR) via Template Literals**.
- *Why?* Zero dependencies. No build step (like Vite/Webpack) required. We can simply write functions that return HTML strings.
- *Client Side:* Minimal Vanilla JS or a lightweight drop-in library (like HTMX or Alpine from CDN) can be used if interactivity is needed later.
### File Organization (`src/web/`)
We will separate the web infrastructure from game modules to keep concerns clean.
- `src/web/server.ts`: Main server class/entry point.
- `src/web/router.ts`: Simple routing logic.
- `src/web/routes/`: Individual route handlers (e.g., `home.ts`, `health.ts`).
- `src/web/views/`: Reusable HTML template functions (Header, Footer, Layouts).
- `src/web/public/`: Static assets (CSS, Images) served directly.
### API / Interface
- **GET /health**: Returns `{ status: "ok", uptime: <seconds> }`.
- **GET /**: Renders a basic HTML landing page using the View system.
## 3. Constraints & Validations (CRITICAL)
- **Zero Frameworks:** No Express/NestJS.
- **Zero Build Tools:** No Webpack/Vite. The code must be runnable directly by `bun run`.
- **Docker Integration:** Port 3000 (or env `PORT`) must be mapped in Docker Compose.
- **Static Files:** Must implement a handler to check `src/web/public` for file requests.
## 4. Acceptance Criteria
1. [x] `docker-compose up` exposes port 3000.
2. [x] `http://localhost:3000` loads a styled HTML page (verifying static asset serving + SSR).
3. [x] `http://localhost:3000/health` returns JSON.
4. [x] Folder structure established as defined above.
## 5. Implementation Plan
- [x] **Infrastructure**: Create `src/web/` directory structure.
- [x] **Core Logic**: Implement `WebServer` class in `src/web/server.ts` with routing and static file serving logic.
- [x] **Integration**: Bind `WebServer.start()` to `src/index.ts`.
- [x] **Docker**: Update `docker-compose.yml` to map port `3000:3000`.
- [x] **Views**: Create a basic `BaseLayout` function in `src/web/views/layout.ts`.
- [x] **Env**: Add `PORT` to `config.ts` / `env.ts`.
## Implementation Notes
- Created `src/web` directory with `router.ts`, `server.ts` and subdirectories `routes`, `views`, `public`.
- Implemented `WebServer` class using `Bun.serve`.
- Added basic CSS and layout system.
- Added `PORT` to `src/lib/env.ts` (default 3000).
- Integrated into `src/index.ts` to start on boot and graceful shutdown.
- Fixed unrelated typing issues in `src/commands/admin/note.ts` and `src/db/indexes.test.ts` to pass strict CI checks.
- Verified with `bun test` and `bun x tsc`.