16 Commits

Author SHA1 Message Date
Vantz Stockwell
ef128b47d2 docs: Add lessons 20-21 — state drift + resilient routing
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Cross-pollinated from parallel instance on sister project. Adapted
to Corrosion context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:04:39 -05:00
Vantz Stockwell
1bb810f851 docs: Add lessons 18-19 to CLAUDE.md — naming drift + UI scaling
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:01:35 -05:00
Vantz Stockwell
b4d1bc8dd0 feat: Add Plugin Configs landing page — collapse 9 sidebar items to 1
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Replace individual plugin config sidebar entries with a single "Plugin Configs"
link that opens a card-based landing page. Cards show status (Active/Configured/
Not Configured), config count, and link to existing editor views. Search bar for
filtering. All existing plugin routes preserved for direct navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 03:29:36 -05:00
Vantz Stockwell
d15ea28e8f feat: Restructure sidebar nav into section-grouped menu
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Replaces flat 25-item navItems array with 6 labeled sections:
Dashboard, Server, Plugin Configs, Operations, Monitoring, Management.
Section headers only render when at least one item is visible to the
user's permissions. Platform Admin section restyled to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:56:39 -05:00
Vantz Stockwell
7d5966839a fix: Resolve vue-tsc -b errors in KitsView and TimedExecuteView
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
KitsView: cast v-for Items array to fix string|number index type mismatch.
TimedExecuteView: remove unused X icon import.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:43:32 -05:00
Vantz Stockwell
2668014068 feat: Add RaidableBases plugin config module — DB migration, NestJS CRUD, Vue editor
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- Migration 021: raidablebases_configs table with JSONB config_data
- Entity, module, controller (7 endpoints), service with NATS deploy/import
- Frontend: 4-tab editor (General, Difficulty, NPC, Loot & Rewards)
- Pinia store, types, router route, sidebar nav with Swords icon
- Top 30 most common settings with actual RaidableBases.json key paths
- Difficulty sub-tabs for Easy/Medium/Hard/Expert/Nightmare with spawn day toggles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:20:21 -05:00
Vantz Stockwell
bb381569e3 feat: Add BetterChat + TimedExecute plugin config modules
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
- DB migrations 017 (betterchat_configs) and 020 (timedexecute_configs) applied
- TypeORM entities matching production schema exactly
- NestJS modules with full CRUD + apply-to-server + import-from-server
- Pinia stores following teleport config pattern
- BetterChatView: Chat Groups editor with color pickers, font sizes, format strings; Settings tab with word filter, anti-flood, player tagging
- TimedExecuteView: TimerRepeat with presets, RealTime-Timer, OnConnect/OnDisconnect command lists
- Wired into app.module.ts, router, DashboardLayout nav

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:19:29 -05:00
Vantz Stockwell
39622de8dc feat: Add Kits + FurnaceSplitter plugin config modules
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
DB migrations 016 (kits_configs) and 019 (furnacesplitter_configs) applied.
Backend: NestJS modules with CRUD, apply-to-server, import-from-server.
Frontend: Pinia stores, Vue views with config editor, router + nav wiring.
Kits view: 3-tab editor (list/editor/settings), kit items with shortname/amount/skinId/container.
FurnaceSplitter view: per-furnace toggles, split count, fuel multiplier settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:19:14 -05:00
Vantz Stockwell
500dca48a5 feat: Add GatherManager + AutoDoors plugin config modules
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- GatherManager: 2-tab editor (Resource Rates with 1x-10x presets,
  Advanced with Pickup/Quarry/Excavator/Survey modifiers), 9 resource
  types with slider+number inputs, CRUD + deploy + import via NATS
- AutoDoors: Global settings (delay sliders, 6 toggles), 7 door type
  toggles, permission group overrides table, CRUD + deploy + import
- DB: migrations 015 (gather_configs) + 018 (autodoors_configs)
- Backend: GatherModule + AutoDoorsModule registered in app.module.ts
- Frontend: Pinia stores, Vue views, router routes, sidebar nav items
- Icons: Pickaxe (gather), DoorOpen (autodoors)
- All type checks pass: tsc + vue-tsc zero errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:17:51 -05:00
Vantz Stockwell
b542f30dcf fix: Remove unused Loader2 import and toast variable from TeleportConfigView
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Fixes Docker build failure — vue-tsc -b treats unused declarations as errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:58:58 -05:00
Vantz Stockwell
6461417b50 feat: Add one-click Oxide/uMod installer — backend + frontend
All checks were successful
Build Companion Agent / build (push) Successful in 24s
Test Asgard Runner / test (push) Successful in 3s
POST /servers/install-oxide endpoint, NATS bridge for oxide.status,
server store installOxide method, ServerView Install Oxide card with
progress tracker matching the Deploy card pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:56:59 -05:00
Vantz Stockwell
380ab2700c feat: Add Oxide/uMod installer package + wire into companion agent daemon
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
New oxide package downloads latest Oxide.Rust release from GitHub,
extracts over the server directory, and restarts the game server.
Progress published to NATS (corrosion.{license_id}.oxide.status).
Heartbeat now reports oxide_installed status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:53:27 -05:00
Vantz Stockwell
585e8aa3f7 feat: Add teleport_configs DB migration + TypeORM entity
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:16:08 -05:00
Vantz Stockwell
4d087132db feat: Add teleport config frontend — Pinia store, views, 2 components, router + nav
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:14:59 -05:00
Vantz Stockwell
16f378eada feat: Add CorrosionTeleportGUI uMod plugin — in-game teleport CUI
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Standalone C# uMod plugin that provides a full-screen CUI teleport
interface for Rust game servers, integrating with NTeleportation.

Features:
- /tpgui chat command opens tabbed overlay (Teleport, Homes, Warps, Settings)
- 4x5 player grid with search filtering and pagination for TPR
- Home management (teleport, set, delete) via NTeleportation API
- Server warp list with teleport buttons
- Incoming TPR accept/deny popup with 30s auto-dismiss
- Settings tab showing cooldowns, limits, NTeleportation status
- Oxide-orange color scheme matching Corrosion brand

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:12:34 -05:00
Vantz Stockwell
3e1af29b38 feat: Add teleport module backend — NestJS CRUD + NATS deploy/import
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Seven endpoints for managing NTeleportation configs: list summaries,
get full config, create, update, delete, deploy to server via NATS,
and import live config from server. Follows loot module pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 01:11:44 -05:00
98 changed files with 12614 additions and 57 deletions

View File

@@ -423,3 +423,11 @@ Things I discovered about myself building a sister platform across multiple sess
16. **Response shape mismatches are silent killers.** The frontend destructures `data.config` and the backend returns the raw entity — no error thrown, no 500, just `undefined` propagating through the template until Vue hits `Cannot read properties of undefined`. The fix is trivial (wrap in `{ config }`), but finding it requires knowing what the frontend expects. Document the contract.
17. **Tools that close the feedback loop are worth 10x their cost.** The debugging bottleneck was never the fix — it was the round-trip of push → rebuild → check → paste → interpret → fix. Playwright and Postgres MCP don't make you smarter, they make you faster. And faster means more iterations, which means better outcomes.
18. **When aggregating across N similar modules, scout for the one that doesn't match the pattern — it's always the oldest or the first-built.** The Loot module was the first plugin config module built, so it uses `fetchProfiles()`/`profiles` while the other 8 use `fetchConfigs()`/`configs`. The first implementation defines its own naming before a convention exists. Every aggregation layer (landing pages, batch operations, monitoring dashboards) will hit this drift. A 30-second recon across all N modules before writing the aggregator prevents a mid-implementation refactor.
19. **UI scaling problems are invisible when you're adding one item at a time — they only become obvious in aggregate.** Nine plugin config sidebar entries were added across multiple sessions, each one reasonable in isolation. Nobody noticed the sidebar was becoming unusable until all nine were there. When building a repeatable pattern (nav items, config modules, API endpoints), build the aggregation layer early — ideally when N hits 3 or 4 — not after it's already painful.
20. **Parallel state fields that track related things will drift apart — and the bugs are silent.** When two fields represent aspects of the same state (`captureMode` and `vkiMode`, or `isLoading` and `error`, or `connection_status` and `companion_last_seen`), every code path that mutates one must also update the other. But new code paths get added over time, and they only update the field they know about. Future me: when you see two fields tracking related state, grep for ALL mutation sites of each — if any path updates one but not the other, that's a bug waiting to happen. And when you add a new mutation path, check every sibling field, not just the obvious one.
21. **Route through the component that survives transitions, not the one that doesn't.** When two systems can handle the same job but one is resilient to failure modes and the other isn't, route through the survivor. Don't build infrastructure to prop up the fragile path when the robust path already exists. In this project: NATS request-reply through the companion agent is the robust path; direct WebSocket to the browser is the fragile one. If a feature can work through either, prefer the path that handles disconnects, reconnects, and restarts gracefully. One routing change beats an entire retry/recovery subsystem.

View File

@@ -36,6 +36,14 @@ import { MigrationModule } from './modules/migration/migration.module';
import { ChangelogModule } from './modules/changelog/changelog.module';
import { FilesModule } from './modules/files/files.module';
import { LootModule } from './modules/loot/loot.module';
import { TeleportModule } from './modules/teleport/teleport.module';
import { GatherModule } from './modules/gather/gather.module';
import { AutoDoorsModule } from './modules/autodoors/autodoors.module';
import { KitsModule } from './modules/kits/kits.module';
import { FurnaceSplitterModule } from './modules/furnacesplitter/furnacesplitter.module';
import { BetterChatModule } from './modules/betterchat/betterchat.module';
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
// Shared Services
import { NatsService } from './services/nats.service';
@@ -107,6 +115,14 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
ChangelogModule,
FilesModule,
LootModule,
TeleportModule,
GatherModule,
AutoDoorsModule,
KitsModule,
FurnaceSplitterModule,
BetterChatModule,
TimedExecuteModule,
RaidableBasesModule,
],
providers: [
// Global guards (order matters: auth first, then license, then permissions)

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('autodoors_configs')
export class AutoDoorsConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('betterchat_configs')
export class BetterChatConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('furnacesplitter_configs')
export class FurnaceSplitterConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('gather_configs')
export class GatherConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('kits_configs')
export class KitsConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('raidablebases_configs')
export class RaidableBasesConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('teleport_configs')
export class TeleportConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,33 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { License } from './license.entity';
@Entity('timedexecute_configs')
export class TimedExecuteConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
license_id: string;
@Column({ type: 'varchar', length: 100 })
config_name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: () => "'{}'" })
config_data: Record<string, any>;
@Column({ type: 'boolean', default: false })
is_active: boolean;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
created_at: Date;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
updated_at: Date;
@ManyToOne(() => License, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'license_id' })
license: License;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { AutoDoorsService } from './autodoors.service';
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
import { ImportAutoDoorsConfigDto } from './dto/import-autodoors-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('autodoors')
@ApiBearerAuth()
@Controller('autodoors')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class AutoDoorsController {
constructor(private readonly autoDoorsService: AutoDoorsService) {}
@Get('configs')
@RequirePermission('autodoors.view')
@ApiOperation({ summary: 'List AutoDoors configs' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.autoDoorsService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('autodoors.view')
@ApiOperation({ summary: 'Get full AutoDoors config' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.autoDoorsService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Create AutoDoors config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateAutoDoorsConfigDto) {
return this.autoDoorsService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Update AutoDoors config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateAutoDoorsConfigDto,
) {
return this.autoDoorsService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Delete AutoDoors config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.autoDoorsService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Deploy AutoDoors config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.autoDoorsService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('autodoors.manage')
@ApiOperation({ summary: 'Import AutoDoors.json from server' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportAutoDoorsConfigDto) {
return this.autoDoorsService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AutoDoorsController } from './autodoors.controller';
import { AutoDoorsService } from './autodoors.service';
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([AutoDoorsConfig])],
controllers: [AutoDoorsController],
providers: [AutoDoorsService, NatsService],
exports: [AutoDoorsService],
})
export class AutoDoorsModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
@Injectable()
export class AutoDoorsService {
private readonly logger = new Logger(AutoDoorsService.name);
constructor(
@InjectRepository(AutoDoorsConfig)
private readonly autoDoorsRepo: Repository<AutoDoorsConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.autoDoorsRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.autoDoorsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('AutoDoors config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateAutoDoorsConfigDto) {
const config = this.autoDoorsRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.autoDoorsRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateAutoDoorsConfigDto) {
const config = await this.autoDoorsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('AutoDoors config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.autoDoorsRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.autoDoorsRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('AutoDoors config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.autoDoorsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('AutoDoors config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write AutoDoors.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/AutoDoors.json',
content: jsonString,
},
30000,
);
// Reload AutoDoors plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload AutoDoors',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.autoDoorsRepo.update({ license_id: licenseId }, { is_active: false });
await this.autoDoorsRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy AutoDoors config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy AutoDoors config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import AutoDoors.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read AutoDoors.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/AutoDoors.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new AutoDoors config row
const config = this.autoDoorsRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.autoDoorsRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import AutoDoors config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import AutoDoors config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateAutoDoorsConfigDto {
@ApiProperty({ example: 'Default AutoDoors' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard auto-close settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportAutoDoorsConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateAutoDoorsConfigDto {
@ApiPropertyOptional({ example: 'Updated Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { BetterChatService } from './betterchat.service';
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
import { ImportBetterChatConfigDto } from './dto/import-betterchat-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('betterchat')
@ApiBearerAuth()
@Controller('betterchat')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class BetterChatController {
constructor(private readonly betterChatService: BetterChatService) {}
@Get('configs')
@RequirePermission('betterchat.view')
@ApiOperation({ summary: 'List BetterChat configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.betterChatService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('betterchat.view')
@ApiOperation({ summary: 'Get full BetterChat config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.betterChatService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Create BetterChat config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateBetterChatConfigDto) {
return this.betterChatService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Update BetterChat config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateBetterChatConfigDto,
) {
return this.betterChatService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Delete BetterChat config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.betterChatService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Deploy BetterChat config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.betterChatService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('betterchat.manage')
@ApiOperation({ summary: 'Import BetterChat.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportBetterChatConfigDto) {
return this.betterChatService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BetterChatController } from './betterchat.controller';
import { BetterChatService } from './betterchat.service';
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([BetterChatConfig])],
controllers: [BetterChatController],
providers: [BetterChatService, NatsService],
exports: [BetterChatService],
})
export class BetterChatModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
@Injectable()
export class BetterChatService {
private readonly logger = new Logger(BetterChatService.name);
constructor(
@InjectRepository(BetterChatConfig)
private readonly repo: Repository<BetterChatConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.repo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('BetterChat config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateBetterChatConfigDto) {
const config = this.repo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.repo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateBetterChatConfigDto) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('BetterChat config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.repo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.repo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('BetterChat config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('BetterChat config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write BetterChat.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/BetterChat.json',
content: jsonString,
},
30000,
);
// Reload BetterChat plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload BetterChat',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.repo.update({ license_id: licenseId }, { is_active: false });
await this.repo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy BetterChat config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy BetterChat config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import BetterChat.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read BetterChat.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/BetterChat.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new config row
const config = this.repo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.repo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import BetterChat config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import BetterChat config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateBetterChatConfigDto {
@ApiProperty({ example: 'Default Chat Config' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard BetterChat settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportBetterChatConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateBetterChatConfigDto {
@ApiPropertyOptional({ example: 'Updated Chat Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateFurnaceSplitterConfigDto {
@ApiProperty({ example: 'Default FurnaceSplitter' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard furnace splitter settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportFurnaceSplitterConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateFurnaceSplitterConfigDto {
@ApiPropertyOptional({ example: 'Updated FurnaceSplitter' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { FurnaceSplitterService } from './furnacesplitter.service';
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
import { ImportFurnaceSplitterConfigDto } from './dto/import-furnacesplitter-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('furnacesplitter')
@ApiBearerAuth()
@Controller('furnacesplitter')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class FurnaceSplitterController {
constructor(private readonly furnaceSplitterService: FurnaceSplitterService) {}
@Get('configs')
@RequirePermission('furnacesplitter.view')
@ApiOperation({ summary: 'List furnace splitter configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.furnaceSplitterService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('furnacesplitter.view')
@ApiOperation({ summary: 'Get full furnace splitter config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.furnaceSplitterService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Create furnace splitter config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateFurnaceSplitterConfigDto) {
return this.furnaceSplitterService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Update furnace splitter config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateFurnaceSplitterConfigDto,
) {
return this.furnaceSplitterService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Delete furnace splitter config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.furnaceSplitterService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Deploy furnace splitter config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.furnaceSplitterService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('furnacesplitter.manage')
@ApiOperation({ summary: 'Import FurnaceSplitter.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportFurnaceSplitterConfigDto) {
return this.furnaceSplitterService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FurnaceSplitterController } from './furnacesplitter.controller';
import { FurnaceSplitterService } from './furnacesplitter.service';
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([FurnaceSplitterConfig])],
controllers: [FurnaceSplitterController],
providers: [FurnaceSplitterService, NatsService],
exports: [FurnaceSplitterService],
})
export class FurnaceSplitterModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto';
import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto';
@Injectable()
export class FurnaceSplitterService {
private readonly logger = new Logger(FurnaceSplitterService.name);
constructor(
@InjectRepository(FurnaceSplitterConfig)
private readonly furnaceRepo: Repository<FurnaceSplitterConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.furnaceRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.furnaceRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateFurnaceSplitterConfigDto) {
const config = this.furnaceRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.furnaceRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateFurnaceSplitterConfigDto) {
const config = await this.furnaceRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.furnaceRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.furnaceRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('FurnaceSplitter config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.furnaceRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('FurnaceSplitter config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write FurnaceSplitter.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/FurnaceSplitter.json',
content: jsonString,
},
30000,
);
// Reload FurnaceSplitter plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload FurnaceSplitter',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.furnaceRepo.update({ license_id: licenseId }, { is_active: false });
await this.furnaceRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy furnace splitter config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy furnace splitter config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import FurnaceSplitter.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read FurnaceSplitter.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/FurnaceSplitter.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new furnace splitter config row
const config = this.furnaceRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.furnaceRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import furnace splitter config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import furnace splitter config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateGatherConfigDto {
@ApiProperty({ example: 'Default 2x Rates' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard 2x gather rates' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportGatherConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateGatherConfigDto {
@ApiPropertyOptional({ example: 'Updated Rates' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { GatherService } from './gather.service';
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
import { ImportGatherConfigDto } from './dto/import-gather-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('gather')
@ApiBearerAuth()
@Controller('gather')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class GatherController {
constructor(private readonly gatherService: GatherService) {}
@Get('configs')
@RequirePermission('gather.view')
@ApiOperation({ summary: 'List gather configs' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.gatherService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('gather.view')
@ApiOperation({ summary: 'Get full gather config' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.gatherService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Create gather config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateGatherConfigDto) {
return this.gatherService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Update gather config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateGatherConfigDto,
) {
return this.gatherService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Delete gather config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.gatherService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Deploy gather config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.gatherService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('gather.manage')
@ApiOperation({ summary: 'Import GatherManager.json from server' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportGatherConfigDto) {
return this.gatherService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GatherController } from './gather.controller';
import { GatherService } from './gather.service';
import { GatherConfig } from '../../entities/gather-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([GatherConfig])],
controllers: [GatherController],
providers: [GatherService, NatsService],
exports: [GatherService],
})
export class GatherModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GatherConfig } from '../../entities/gather-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
@Injectable()
export class GatherService {
private readonly logger = new Logger(GatherService.name);
constructor(
@InjectRepository(GatherConfig)
private readonly gatherRepo: Repository<GatherConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.gatherRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.gatherRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Gather config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateGatherConfigDto) {
const config = this.gatherRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.gatherRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateGatherConfigDto) {
const config = await this.gatherRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Gather config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.gatherRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.gatherRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('Gather config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.gatherRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Gather config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write GatherManager.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/GatherManager.json',
content: jsonString,
},
30000,
);
// Reload GatherManager plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload GatherManager',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.gatherRepo.update({ license_id: licenseId }, { is_active: false });
await this.gatherRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy gather config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy gather config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import GatherManager.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read GatherManager.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/GatherManager.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new gather config row
const config = this.gatherRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.gatherRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import gather config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import gather config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateKitsConfigDto {
@ApiProperty({ example: 'Default Kits' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard kit configuration' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportKitsConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateKitsConfigDto {
@ApiPropertyOptional({ example: 'Updated Kits' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { KitsService } from './kits.service';
import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
import { ImportKitsConfigDto } from './dto/import-kits-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('kits')
@ApiBearerAuth()
@Controller('kits')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class KitsController {
constructor(private readonly kitsService: KitsService) {}
@Get('configs')
@RequirePermission('kits.view')
@ApiOperation({ summary: 'List kits configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.kitsService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('kits.view')
@ApiOperation({ summary: 'Get full kits config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.kitsService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Create kits config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateKitsConfigDto) {
return this.kitsService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Update kits config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateKitsConfigDto,
) {
return this.kitsService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Delete kits config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.kitsService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Deploy kits config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.kitsService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('kits.manage')
@ApiOperation({ summary: 'Import Kits.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportKitsConfigDto) {
return this.kitsService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { KitsController } from './kits.controller';
import { KitsService } from './kits.service';
import { KitsConfig } from '../../entities/kits-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([KitsConfig])],
controllers: [KitsController],
providers: [KitsService, NatsService],
exports: [KitsService],
})
export class KitsModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { KitsConfig } from '../../entities/kits-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateKitsConfigDto } from './dto/create-kits-config.dto';
import { UpdateKitsConfigDto } from './dto/update-kits-config.dto';
@Injectable()
export class KitsService {
private readonly logger = new Logger(KitsService.name);
constructor(
@InjectRepository(KitsConfig)
private readonly kitsRepo: Repository<KitsConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.kitsRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.kitsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Kits config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateKitsConfigDto) {
const config = this.kitsRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.kitsRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateKitsConfigDto) {
const config = await this.kitsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Kits config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.kitsRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.kitsRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('Kits config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.kitsRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Kits config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write Kits.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/Kits.json',
content: jsonString,
},
30000,
);
// Reload Kits plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload Kits',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.kitsRepo.update({ license_id: licenseId }, { is_active: false });
await this.kitsRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy kits config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy kits config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import Kits.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read Kits.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/Kits.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new kits config row
const config = this.kitsRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.kitsRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import kits config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import kits config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateRaidableBasesConfigDto {
@ApiProperty({ example: 'Default RaidableBases Config' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard RaidableBases settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportRaidableBasesConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateRaidableBasesConfigDto {
@ApiPropertyOptional({ example: 'Updated Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { RaidableBasesService } from './raidablebases.service';
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
import { ImportRaidableBasesConfigDto } from './dto/import-raidablebases-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('raidablebases')
@ApiBearerAuth()
@Controller('raidablebases')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class RaidableBasesController {
constructor(private readonly raidableBasesService: RaidableBasesService) {}
@Get('configs')
@RequirePermission('raidablebases.view')
@ApiOperation({ summary: 'List RaidableBases configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.raidableBasesService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('raidablebases.view')
@ApiOperation({ summary: 'Get full RaidableBases config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.raidableBasesService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Create RaidableBases config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateRaidableBasesConfigDto) {
return this.raidableBasesService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Update RaidableBases config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateRaidableBasesConfigDto,
) {
return this.raidableBasesService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Delete RaidableBases config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.raidableBasesService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Deploy RaidableBases config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.raidableBasesService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('raidablebases.manage')
@ApiOperation({ summary: 'Import RaidableBases.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportRaidableBasesConfigDto) {
return this.raidableBasesService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RaidableBasesController } from './raidablebases.controller';
import { RaidableBasesService } from './raidablebases.service';
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([RaidableBasesConfig])],
controllers: [RaidableBasesController],
providers: [RaidableBasesService, NatsService],
exports: [RaidableBasesService],
})
export class RaidableBasesModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
@Injectable()
export class RaidableBasesService {
private readonly logger = new Logger(RaidableBasesService.name);
constructor(
@InjectRepository(RaidableBasesConfig)
private readonly raidableBasesRepo: Repository<RaidableBasesConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.raidableBasesRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.raidableBasesRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('RaidableBases config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateRaidableBasesConfigDto) {
const config = this.raidableBasesRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.raidableBasesRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateRaidableBasesConfigDto) {
const config = await this.raidableBasesRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('RaidableBases config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.raidableBasesRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.raidableBasesRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('RaidableBases config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.raidableBasesRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('RaidableBases config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write RaidableBases.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/RaidableBases.json',
content: jsonString,
},
30000,
);
// Reload RaidableBases plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload RaidableBases',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.raidableBasesRepo.update({ license_id: licenseId }, { is_active: false });
await this.raidableBasesRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy RaidableBases config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy RaidableBases config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import RaidableBases.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read RaidableBases.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/RaidableBases.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new RaidableBases config row
const config = this.raidableBasesRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.raidableBasesRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import RaidableBases config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import RaidableBases config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -73,4 +73,11 @@ export class ServersController {
) {
return await this.serversService.deployServer(licenseId, dto);
}
@Post('install-oxide')
@RequirePermission('server.manage')
@ApiOperation({ summary: 'Install Oxide/uMod via companion agent' })
async installOxide(@CurrentTenant() licenseId: string) {
return await this.serversService.installOxide(licenseId);
}
}

View File

@@ -103,4 +103,12 @@ export class ServersService {
await this.natsService.sendDeployCommand(licenseId, { ...dto });
return { message: 'Deployment started' };
}
/**
* Install Oxide/uMod via companion agent
*/
async installOxide(licenseId: string) {
await this.natsService.sendOxideInstallCommand(licenseId);
return { message: 'Oxide installation started' };
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTeleportConfigDto {
@ApiProperty({ example: 'Default Config' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard NTeleportation settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportTeleportConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateTeleportConfigDto {
@ApiPropertyOptional({ example: 'Updated Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TeleportService } from './teleport.service';
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
import { ImportTeleportConfigDto } from './dto/import-teleport-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('teleport')
@ApiBearerAuth()
@Controller('teleport')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class TeleportController {
constructor(private readonly teleportService: TeleportService) {}
@Get('configs')
@RequirePermission('teleport.view')
@ApiOperation({ summary: 'List teleport configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.teleportService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('teleport.view')
@ApiOperation({ summary: 'Get full teleport config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.teleportService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Create teleport config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateTeleportConfigDto) {
return this.teleportService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Update teleport config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateTeleportConfigDto,
) {
return this.teleportService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Delete teleport config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.teleportService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Deploy teleport config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.teleportService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('teleport.manage')
@ApiOperation({ summary: 'Import NTeleportation.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportTeleportConfigDto) {
return this.teleportService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TeleportController } from './teleport.controller';
import { TeleportService } from './teleport.service';
import { TeleportConfig } from '../../entities/teleport-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([TeleportConfig])],
controllers: [TeleportController],
providers: [TeleportService, NatsService],
exports: [TeleportService],
})
export class TeleportModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TeleportConfig } from '../../entities/teleport-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
@Injectable()
export class TeleportService {
private readonly logger = new Logger(TeleportService.name);
constructor(
@InjectRepository(TeleportConfig)
private readonly teleportRepo: Repository<TeleportConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.teleportRepo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.teleportRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Teleport config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateTeleportConfigDto) {
const config = this.teleportRepo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.teleportRepo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateTeleportConfigDto) {
const config = await this.teleportRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Teleport config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.teleportRepo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.teleportRepo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('Teleport config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.teleportRepo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('Teleport config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write NTeleportation.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/NTeleportation.json',
content: jsonString,
},
30000,
);
// Reload NTeleportation plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload NTeleportation',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.teleportRepo.update({ license_id: licenseId }, { is_active: false });
await this.teleportRepo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy teleport config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy teleport config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import NTeleportation.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read NTeleportation.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/NTeleportation.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new teleport config row
const config = this.teleportRepo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.teleportRepo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import teleport config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import teleport config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTimedExecuteConfigDto {
@ApiProperty({ example: 'Default Timer Config' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Standard TimedExecute settings' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
}

View File

@@ -0,0 +1,14 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportTimedExecuteConfigDto {
@ApiProperty({ example: 'Server Import' })
@IsString()
@MaxLength(100)
config_name: string;
@ApiPropertyOptional({ example: 'Imported from live server' })
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateTimedExecuteConfigDto {
@ApiPropertyOptional({ example: 'Updated Timer Config' })
@IsString()
@MaxLength(100)
@IsOptional()
config_name?: string;
@ApiPropertyOptional({ example: 'Updated description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional()
@IsObject()
@IsOptional()
config_data?: Record<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TimedExecuteService } from './timedexecute.service';
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
import { ImportTimedExecuteConfigDto } from './dto/import-timedexecute-config.dto';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
@ApiTags('timedexecute')
@ApiBearerAuth()
@Controller('timedexecute')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class TimedExecuteController {
constructor(private readonly timedExecuteService: TimedExecuteService) {}
@Get('configs')
@RequirePermission('timedexecute.view')
@ApiOperation({ summary: 'List TimedExecute configs (summaries)' })
getConfigs(@CurrentTenant() licenseId: string) {
return this.timedExecuteService.getConfigs(licenseId);
}
@Get('configs/:id')
@RequirePermission('timedexecute.view')
@ApiOperation({ summary: 'Get full TimedExecute config with data' })
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.timedExecuteService.getConfig(licenseId, id);
}
@Post('configs')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Create TimedExecute config' })
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateTimedExecuteConfigDto) {
return this.timedExecuteService.createConfig(licenseId, dto);
}
@Put('configs/:id')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Update TimedExecute config' })
updateConfig(
@CurrentTenant() licenseId: string,
@Param('id') id: string,
@Body() dto: UpdateTimedExecuteConfigDto,
) {
return this.timedExecuteService.updateConfig(licenseId, id, dto);
}
@Delete('configs/:id')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Delete TimedExecute config' })
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.timedExecuteService.deleteConfig(licenseId, id);
}
@Post('configs/:id/apply')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Deploy TimedExecute config to server' })
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
return this.timedExecuteService.applyToServer(licenseId, id);
}
@Post('import-from-server')
@RequirePermission('timedexecute.manage')
@ApiOperation({ summary: 'Import TimedExecute.json from server via NATS' })
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportTimedExecuteConfigDto) {
return this.timedExecuteService.importFromServer(licenseId, dto.config_name, dto.description);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TimedExecuteController } from './timedexecute.controller';
import { TimedExecuteService } from './timedexecute.service';
import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([TimedExecuteConfig])],
controllers: [TimedExecuteController],
providers: [TimedExecuteService, NatsService],
exports: [TimedExecuteService],
})
export class TimedExecuteModule {}

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
import { NatsService } from '../../services/nats.service';
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
@Injectable()
export class TimedExecuteService {
private readonly logger = new Logger(TimedExecuteService.name);
constructor(
@InjectRepository(TimedExecuteConfig)
private readonly repo: Repository<TimedExecuteConfig>,
private readonly natsService: NatsService,
) {}
/** List configs for a license (summaries — no JSONB) */
async getConfigs(licenseId: string) {
const configs = await this.repo.find({
where: { license_id: licenseId },
select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'],
order: { created_at: 'DESC' },
});
return { configs };
}
/** Get full config with JSONB data */
async getConfig(licenseId: string, configId: string) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('TimedExecute config not found');
return { config };
}
/** Create a new config */
async createConfig(licenseId: string, dto: CreateTimedExecuteConfigDto) {
const config = this.repo.create({
license_id: licenseId,
config_name: dto.config_name,
description: dto.description || null,
config_data: dto.config_data || {},
});
const saved = await this.repo.save(config);
return { config: saved };
}
/** Update an existing config */
async updateConfig(licenseId: string, configId: string, dto: UpdateTimedExecuteConfigDto) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('TimedExecute config not found');
if (dto.config_name !== undefined) config.config_name = dto.config_name;
if (dto.description !== undefined) config.description = dto.description;
if (dto.config_data !== undefined) config.config_data = dto.config_data;
if (dto.is_active !== undefined) config.is_active = dto.is_active;
config.updated_at = new Date();
const saved = await this.repo.save(config);
return { config: saved };
}
/** Delete a config */
async deleteConfig(licenseId: string, configId: string) {
const result = await this.repo.delete({ id: configId, license_id: licenseId });
if (result.affected === 0) throw new NotFoundException('TimedExecute config not found');
return { deleted: true };
}
/** Deploy config to game server via NATS */
async applyToServer(licenseId: string, configId: string) {
const config = await this.repo.findOne({
where: { id: configId, license_id: licenseId },
});
if (!config) throw new NotFoundException('TimedExecute config not found');
const jsonString = JSON.stringify(config.config_data, null, 2);
try {
// Write TimedExecute.json via file manager NATS
await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_save',
path: 'server://oxide/config/TimedExecute.json',
content: jsonString,
},
30000,
);
// Reload TimedExecute plugin via RCON
await this.natsService.publish(
`corrosion.${licenseId}.cmd.server`,
{
action: 'command',
command: 'oxide.reload TimedExecute',
timestamp: new Date().toISOString(),
},
);
// Mark this config as active, deactivate others
await this.repo.update({ license_id: licenseId }, { is_active: false });
await this.repo.update(
{ id: configId, license_id: licenseId },
{ is_active: true, updated_at: new Date() },
);
return {
success: true,
message: `Config "${config.config_name}" deployed to server`,
config_name: config.config_name,
};
} catch (error) {
this.logger.error(`Failed to deploy TimedExecute config: ${(error as Error).message}`);
throw new HttpException(
'Failed to deploy TimedExecute config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
/** Import TimedExecute.json from game server via NATS */
async importFromServer(licenseId: string, configName: string, description?: string) {
try {
// Read TimedExecute.json from server via file manager NATS
const response = await this.natsService.request(
`corrosion.${licenseId}.files.cmd`,
{
func: 'fm_preview',
path: 'server://oxide/config/TimedExecute.json',
},
30000,
);
if (!response) {
throw new HttpException(
'No response from agent — it may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Parse the response content as JSON
const responseData = response as Record<string, any>;
let configData: Record<string, any>;
if (typeof responseData.content === 'string') {
configData = JSON.parse(responseData.content);
} else if (typeof responseData.content === 'object') {
configData = responseData.content;
} else {
throw new HttpException(
'Unexpected response format from agent',
HttpStatus.BAD_GATEWAY,
);
}
// Create new config row
const config = this.repo.create({
license_id: licenseId,
config_name: configName,
description: description || 'Imported from server',
config_data: configData,
});
const saved = await this.repo.save(config);
return { config: saved };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Failed to import TimedExecute config from server: ${(error as Error).message}`);
throw new HttpException(
'Failed to import TimedExecute config — agent may be offline',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}

View File

@@ -39,6 +39,11 @@ export class NatsBridgeService implements OnModuleInit {
this.emit(licenseId, 'deploy_status', data);
});
this.nats.subscribe('corrosion.*.oxide.status', (data, subject) => {
const licenseId = subject.split('.')[1];
this.emit(licenseId, 'oxide_status', data);
});
this.logger.log('NATS bridge subscriptions initialized');
}

View File

@@ -79,4 +79,12 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
timestamp: new Date().toISOString(),
});
}
/** Publish an Oxide install command to a specific license's companion agent */
async sendOxideInstallCommand(licenseId: string): Promise<void> {
await this.publish(`corrosion.${licenseId}.cmd.oxide`, {
action: 'install_oxide',
timestamp: new Date().toISOString(),
});
}
}

View File

@@ -0,0 +1,12 @@
-- Teleport configuration profiles for NTeleportation integration
CREATE TABLE teleport_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_teleport_configs_license ON teleport_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS gather_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_gather_configs_license ON gather_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS kits_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_kits_configs_license ON kits_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS betterchat_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_betterchat_configs_license ON betterchat_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS autodoors_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_autodoors_configs_license ON autodoors_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS furnacesplitter_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_furnacesplitter_configs_license ON furnacesplitter_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS timedexecute_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_timedexecute_configs_license ON timedexecute_configs(license_id);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS raidablebases_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
config_name VARCHAR(100) NOT NULL,
description TEXT,
config_data JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_raidablebases_configs_license ON raidablebases_configs(license_id);

View File

@@ -11,6 +11,7 @@ import (
"github.com/nats-io/nats.go"
"github.com/vigilcyber/corrosion-companion/internal/deploy"
"github.com/vigilcyber/corrosion-companion/internal/filemanager"
"github.com/vigilcyber/corrosion-companion/internal/oxide"
"github.com/vigilcyber/corrosion-companion/internal/files"
"github.com/vigilcyber/corrosion-companion/internal/process"
"github.com/vigilcyber/corrosion-companion/internal/rcon"
@@ -32,14 +33,15 @@ type DaemonConfig struct {
// Daemon manages the companion agent's main operations
type Daemon struct {
nc *nats.Conn
cfg *DaemonConfig
gameServer *process.GameServer
fileOps *files.Operations
fm *filemanager.FileManager
updater *update.Updater
deployer *deploy.Deployer
subscriptions []*nats.Subscription
nc *nats.Conn
cfg *DaemonConfig
gameServer *process.GameServer
fileOps *files.Operations
fm *filemanager.FileManager
updater *update.Updater
deployer *deploy.Deployer
oxideInstaller *oxide.OxideInstaller
subscriptions []*nats.Subscription
}
// HeartbeatPayload represents the data sent in heartbeat messages
@@ -56,6 +58,7 @@ type HeartbeatPayload struct {
OS string `json:"os"`
Arch string `json:"arch"`
ServerInstalled bool `json:"server_installed"`
OxideInstalled bool `json:"oxide_installed"`
}
// gameServerAdapter wraps process.GameServer to satisfy deploy.GameServerStarter
@@ -74,6 +77,15 @@ func (a *gameServerAdapter) UpdatePath(path string) {
*a.gs = *process.NewGameServer(path, a.cfg.GameServerArgs)
}
// restartAdapter wraps process.GameServer to satisfy oxide.GameServerRestarter
type restartAdapter struct {
gs *process.GameServer
}
func (a *restartAdapter) Restart() error {
return a.gs.Restart()
}
// NewDaemon creates a new daemon instance
func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
@@ -82,15 +94,18 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
updater := update.NewUpdater(cfg.Version)
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
restarter := &restartAdapter{gs: gameServer}
oxideInst := oxide.NewOxideInstaller(nc, cfg.LicenseID, cfg.InstallDir, restarter)
d := &Daemon{
nc: nc,
cfg: cfg,
gameServer: gameServer,
fileOps: fileOps,
fm: fm,
updater: updater,
deployer: deployer,
nc: nc,
cfg: cfg,
gameServer: gameServer,
fileOps: fileOps,
fm: fm,
updater: updater,
deployer: deployer,
oxideInstaller: oxideInst,
}
return d, nil
@@ -125,6 +140,11 @@ func (d *Daemon) Run(ctx context.Context) error {
return fmt.Errorf("failed to subscribe to deploy commands: %w", err)
}
// Subscribe to Oxide install commands
if err := d.subscribeOxideInstall(); err != nil {
return fmt.Errorf("failed to subscribe to oxide install commands: %w", err)
}
// Subscribe to file manager commands (VueFinder-compatible request-reply)
if err := d.subscribeFileManager(); err != nil {
return fmt.Errorf("failed to subscribe to file manager commands: %w", err)
@@ -389,6 +409,38 @@ func (d *Daemon) subscribeFileManager() error {
return nil
}
// subscribeOxideInstall subscribes to Oxide installation commands
func (d *Daemon) subscribeOxideInstall() error {
subject := fmt.Sprintf("corrosion.%s.cmd.oxide", d.cfg.LicenseID)
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
log.Println("Received Oxide install command")
// Run installation in goroutine (it's long-running)
go func() {
if err := d.oxideInstaller.Install(); err != nil {
log.Printf("Oxide installation failed: %v", err)
} else {
log.Println("Oxide installation completed successfully")
}
}()
// Immediately acknowledge the command
d.respondSuccess(msg, map[string]interface{}{
"status": "accepted",
"message": "Oxide installation started, progress will be published to oxide.status",
})
})
if err != nil {
return err
}
d.subscriptions = append(d.subscriptions, sub)
log.Printf("Subscribed to: %s", subject)
return nil
}
// handleFileOperation processes file operation requests
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
// Parse common fields
@@ -459,6 +511,7 @@ func (d *Daemon) publishHeartbeat() {
OS: runtime.GOOS,
Arch: runtime.GOARCH,
ServerInstalled: deploy.CheckServerInstalled(d.cfg.InstallDir),
OxideInstalled: oxide.CheckOxideInstalled(d.cfg.InstallDir),
}
data, err := json.Marshal(payload)

View File

@@ -0,0 +1,250 @@
package oxide
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/nats-io/nats.go"
)
// GameServerRestarter abstracts the game server process manager so the installer
// can restart the server after extracting Oxide files.
type GameServerRestarter interface {
Restart() error
}
// OxideInstaller handles downloading and extracting Oxide/uMod over a Rust server installation.
type OxideInstaller struct {
nc *nats.Conn
licenseID string
installDir string
gameServer GameServerRestarter
}
// NewOxideInstaller creates a new OxideInstaller instance.
func NewOxideInstaller(nc *nats.Conn, licenseID, installDir string, gs GameServerRestarter) *OxideInstaller {
return &OxideInstaller{
nc: nc,
licenseID: licenseID,
installDir: installDir,
gameServer: gs,
}
}
// githubRelease represents the relevant fields from the GitHub Releases API response.
type githubRelease struct {
TagName string `json:"tag_name"`
Assets []githubAsset `json:"assets"`
}
type githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// Install performs the full Oxide installation pipeline:
// 1. Fetch latest release info from GitHub
// 2. Download the zip
// 3. Extract over {installDir}/server/
// 4. Restart the game server
func (o *OxideInstaller) Install() error {
// Stage 1: Fetch latest release
log.Printf("Oxide: fetching latest release for license %s", o.licenseID)
o.publishStatus("fetching_release", 0, "Checking latest Oxide release...")
release, err := o.fetchLatestRelease()
if err != nil {
o.publishStatus("failed", 0, "Failed to fetch Oxide release info", err.Error())
return fmt.Errorf("fetch release failed: %w", err)
}
if len(release.Assets) == 0 {
err := fmt.Errorf("no assets found in release %s", release.TagName)
o.publishStatus("failed", 0, "No download assets in release", err.Error())
return err
}
downloadURL := release.Assets[0].BrowserDownloadURL
version := release.TagName
log.Printf("Oxide: latest version is %s, download URL: %s", version, downloadURL)
o.publishStatus("fetching_release", 100, fmt.Sprintf("Found Oxide %s", version))
// Stage 2: Download zip
log.Printf("Oxide: downloading %s", downloadURL)
o.publishStatus("downloading", 0, fmt.Sprintf("Downloading Oxide %s...", version))
tmpPath := filepath.Join(os.TempDir(), "oxide-latest.zip")
if err := o.downloadFile(downloadURL, tmpPath); err != nil {
o.publishStatus("failed", 0, "Failed to download Oxide", err.Error())
return fmt.Errorf("download failed: %w", err)
}
defer os.Remove(tmpPath)
log.Printf("Oxide: download complete")
o.publishStatus("downloading", 100, "Download complete")
// Stage 3: Extract over server directory
serverDir := filepath.Join(o.installDir, "server")
log.Printf("Oxide: extracting to %s", serverDir)
o.publishStatus("installing", 0, "Extracting Oxide over server directory...")
if err := o.extractZip(tmpPath, serverDir); err != nil {
o.publishStatus("failed", 0, "Failed to extract Oxide", err.Error())
return fmt.Errorf("extract failed: %w", err)
}
log.Printf("Oxide: extraction complete")
o.publishStatus("installing", 100, "Oxide files extracted")
// Stage 4: Restart server
log.Printf("Oxide: restarting server")
o.publishStatus("restarting", 0, "Restarting server to load Oxide...")
if err := o.gameServer.Restart(); err != nil {
o.publishStatus("failed", 0, "Server restart failed", err.Error())
return fmt.Errorf("server restart failed: %w", err)
}
log.Printf("Oxide: server restarted, installation complete")
o.publishStatus("complete", 100, fmt.Sprintf("Oxide %s installed successfully", version))
return nil
}
// fetchLatestRelease queries the GitHub API for the latest Oxide.Rust release.
func (o *OxideInstaller) fetchLatestRelease() (*githubRelease, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get("https://api.github.com/repos/OxideMod/Oxide.Rust/releases/latest")
if err != nil {
return nil, fmt.Errorf("GitHub API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("failed to parse GitHub API response: %w", err)
}
return &release, nil
}
// downloadFile downloads a URL to a local file path.
func (o *OxideInstaller) downloadFile(url, destPath string) error {
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("HTTP GET failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned status %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", destPath, err)
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("failed to write download: %w", err)
}
return nil
}
// extractZip extracts a zip file to a destination directory, overwriting existing files.
// This is used to overlay Oxide's DLLs over the Rust server's Managed directory
// and create the oxide/ folder structure.
func (o *OxideInstaller) extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("failed to open zip: %w", err)
}
defer r.Close()
for _, f := range r.File {
targetPath := filepath.Join(destDir, f.Name)
// Security: prevent path traversal
if !strings.HasPrefix(targetPath, filepath.Clean(destDir)+string(os.PathSeparator)) && targetPath != filepath.Clean(destDir) {
log.Printf("Oxide: skipping potentially unsafe path: %s", f.Name)
continue
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(targetPath, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", targetPath, err)
}
continue
}
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return fmt.Errorf("failed to create parent directory for %s: %w", targetPath, err)
}
outFile, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return fmt.Errorf("failed to create file %s: %w", targetPath, err)
}
rc, err := f.Open()
if err != nil {
outFile.Close()
return fmt.Errorf("failed to open zip entry %s: %w", f.Name, err)
}
_, err = io.Copy(outFile, rc)
rc.Close()
outFile.Close()
if err != nil {
return fmt.Errorf("failed to extract %s: %w", f.Name, err)
}
}
return nil
}
// publishStatus publishes an OxideStatus message to NATS. Publish errors are logged
// but do not fail the installation — losing a progress update is not fatal.
func (o *OxideInstaller) publishStatus(stage string, progress int, message string, errDetail ...string) {
subject := fmt.Sprintf("corrosion.%s.oxide.status", o.licenseID)
status := OxideStatus{
Stage: stage,
Progress: progress,
Message: message,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if len(errDetail) > 0 && errDetail[0] != "" {
status.Error = errDetail[0]
}
data, err := json.Marshal(status)
if err != nil {
log.Printf("Failed to marshal oxide status: %v", err)
return
}
if err := o.nc.Publish(subject, data); err != nil {
log.Printf("Failed to publish oxide status to %s: %v", subject, err)
}
}

View File

@@ -0,0 +1,31 @@
package oxide
import (
"os"
"path/filepath"
)
// OxideStatus represents a progress update published to NATS during Oxide installation.
// The frontend listens on corrosion.{license_id}.oxide.status for these messages.
type OxideStatus struct {
Stage string `json:"stage"`
Progress int `json:"progress"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
Timestamp string `json:"timestamp"`
}
// Valid installation stages:
// fetching_release - Querying GitHub API for latest Oxide.Rust release
// downloading - Downloading the Oxide zip file
// installing - Extracting zip over server directory
// restarting - Restarting the game server to load Oxide
// complete - Oxide installation finished successfully
// failed - Installation failed at some stage
// CheckOxideInstalled returns true if the oxide/ directory exists in the
// server installation directory, indicating that Oxide/uMod has been installed.
func CheckOxideInstalled(installDir string) bool {
_, err := os.Stat(filepath.Join(installDir, "server", "oxide"))
return err == nil
}

View File

@@ -27,7 +27,6 @@ import {
AlertTriangle,
FileText,
FolderOpen,
Crosshair,
Menu,
X,
} from 'lucide-vue-next'
@@ -38,26 +37,59 @@ const auth = useAuthStore()
const server = useServerStore()
const sidebarOpen = ref(false)
const navItems = [
{ name: 'Dashboard', path: '/', icon: LayoutDashboard, permission: null },
{ name: 'Server', path: '/server', icon: Server, permission: 'server.view' },
{ name: 'Console', path: '/console', icon: Terminal, permission: 'console.view' },
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
{ name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.view' },
{ name: 'Loot Builder', path: '/loot-builder', icon: Crosshair, permission: 'loot.view' },
{ name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' },
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
{ name: 'Analytics', path: '/analytics', icon: BarChart3, permission: 'analytics.view' },
{ name: 'Schedules', path: '/schedules', icon: Clock, permission: 'schedules.view' },
{ name: 'Alerts', path: '/alerts', icon: AlertTriangle, permission: 'alerts.view' },
{ name: 'Notifications', path: '/notifications', icon: Bell, permission: 'notifications.view' },
{ name: 'Team', path: '/team', icon: UserPlus, permission: null },
{ name: 'Store', path: '/store/config', icon: ShoppingBag, permission: 'store.view' },
{ name: 'Modules', path: '/modules', icon: Package, permission: 'modules.view' },
{ name: 'Changelog', path: '/changelog', icon: FileText, permission: 'changelog.view' },
{ name: 'Settings', path: '/settings', icon: Settings, permission: 'settings.view' },
type NavItem = { name: string; path: string; icon: any; permission: string | null }
type NavSection = { label: string; items: NavItem[] }
const navSections: NavSection[] = [
{
label: '',
items: [
{ name: 'Dashboard', path: '/', icon: LayoutDashboard, permission: null },
],
},
{
label: 'Server',
items: [
{ name: 'Server', path: '/server', icon: Server, permission: 'server.view' },
{ name: 'Console', path: '/console', icon: Terminal, permission: 'console.view' },
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
{ name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.view' },
],
},
{
label: 'Plugin Configs',
items: [
{ name: 'Plugin Configs', path: '/plugin-configs', icon: Puzzle, permission: null },
],
},
{
label: 'Operations',
items: [
{ name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' },
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
{ name: 'Schedules', path: '/schedules', icon: Clock, permission: 'schedules.view' },
],
},
{
label: 'Monitoring',
items: [
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
{ name: 'Analytics', path: '/analytics', icon: BarChart3, permission: 'analytics.view' },
{ name: 'Alerts', path: '/alerts', icon: AlertTriangle, permission: 'alerts.view' },
{ name: 'Notifications', path: '/notifications', icon: Bell, permission: 'notifications.view' },
],
},
{
label: 'Management',
items: [
{ name: 'Team', path: '/team', icon: UserPlus, permission: null },
{ name: 'Store', path: '/store/config', icon: ShoppingBag, permission: 'store.view' },
{ name: 'Modules', path: '/modules', icon: Package, permission: 'modules.view' },
{ name: 'Changelog', path: '/changelog', icon: FileText, permission: 'changelog.view' },
{ name: 'Settings', path: '/settings', icon: Settings, permission: 'settings.view' },
],
},
]
const adminNavItems = [
@@ -82,10 +114,14 @@ function closeSidebar() {
sidebarOpen.value = false
}
function canShowNavItem(item: typeof navItems[0]): boolean {
function canShowNavItem(item: NavItem): boolean {
if (!item.permission) return true
return auth.hasPermission(item.permission)
}
function hasVisibleItems(section: NavSection): boolean {
return section.items.some(canShowNavItem)
}
</script>
<template>
@@ -148,29 +184,35 @@ function canShowNavItem(item: typeof navItems[0]): boolean {
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto py-2">
<RouterLink
v-for="item in navItems"
v-show="canShowNavItem(item)"
:key="item.path"
:to="item.path"
@click="closeSidebar"
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
:class="isActive(item.path)
? 'bg-oxide-500/10 text-oxide-400'
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
>
<component :is="item.icon" class="w-4 h-4" />
{{ item.name }}
</RouterLink>
<template v-for="section in navSections" :key="section.label">
<template v-if="hasVisibleItems(section)">
<!-- Section Header -->
<div v-if="section.label" class="mt-4 mb-1 px-4">
<span class="text-[10px] font-semibold uppercase tracking-widest text-neutral-500">{{ section.label }}</span>
</div>
<!-- Section Items -->
<RouterLink
v-for="item in section.items"
v-show="canShowNavItem(item)"
:key="item.path"
:to="item.path"
@click="closeSidebar"
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
:class="isActive(item.path)
? 'bg-oxide-500/10 text-oxide-400'
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
>
<component :is="item.icon" class="w-4 h-4" />
{{ item.name }}
</RouterLink>
</template>
</template>
<!-- Platform Admin Section (super-admin only) -->
<template v-if="auth.isSuperAdmin">
<div class="mt-4 mb-2 px-4">
<div class="flex items-center gap-2">
<div class="flex-1 border-t border-neutral-700" />
<span class="text-[10px] font-semibold uppercase tracking-widest text-oxide-500">Platform</span>
<div class="flex-1 border-t border-neutral-700" />
</div>
<div class="mt-4 mb-1 px-4">
<span class="text-[10px] font-semibold uppercase tracking-widest text-oxide-500">Platform</span>
</div>
<RouterLink
v-for="item in adminNavItems"

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
const props = defineProps<{
configData: Record<string, any>
}>()
const emit = defineEmits<{
'update:configData': [configData: Record<string, any>]
}>()
const newGroupName = ref('')
// Merge all VIP maps by key name to compute the unified group list
const groups = computed(() => {
const homesLimits: Record<string, number> = props.configData?.Home?.VIPHomesLimits || {}
const cooldowns: Record<string, number> = props.configData?.TPR?.VIPCooldowns || {}
const countdowns: Record<string, number> = props.configData?.TPR?.VIPCountdowns || {}
const dailyLimits: Record<string, number> = props.configData?.TPR?.VIPDailyLimits || {}
const allKeys = new Set([
...Object.keys(homesLimits),
...Object.keys(cooldowns),
...Object.keys(countdowns),
...Object.keys(dailyLimits),
])
return Array.from(allKeys).map(name => ({
name,
homesLimit: homesLimits[name] ?? 5,
cooldown: cooldowns[name] ?? 300,
countdown: countdowns[name] ?? 5,
dailyLimit: dailyLimits[name] ?? 10,
}))
})
function ensurePaths(data: Record<string, any>) {
if (!data.Home) data.Home = {}
if (!data.Home.VIPHomesLimits) data.Home.VIPHomesLimits = {}
if (!data.TPR) data.TPR = {}
if (!data.TPR.VIPCooldowns) data.TPR.VIPCooldowns = {}
if (!data.TPR.VIPCountdowns) data.TPR.VIPCountdowns = {}
if (!data.TPR.VIPDailyLimits) data.TPR.VIPDailyLimits = {}
}
function addGroup() {
const name = newGroupName.value.trim()
if (!name) return
// Check if already exists
if (groups.value.some(g => g.name === name)) return
const updated = { ...props.configData }
ensurePaths(updated)
updated.Home.VIPHomesLimits[name] = 5
updated.TPR.VIPCooldowns[name] = 300
updated.TPR.VIPCountdowns[name] = 5
updated.TPR.VIPDailyLimits[name] = 10
emit('update:configData', updated)
newGroupName.value = ''
}
function removeGroup(name: string) {
if (!confirm(`Remove VIP group "${name}"?`)) return
const updated = { ...props.configData }
ensurePaths(updated)
delete updated.Home.VIPHomesLimits[name]
delete updated.TPR.VIPCooldowns[name]
delete updated.TPR.VIPCountdowns[name]
delete updated.TPR.VIPDailyLimits[name]
emit('update:configData', updated)
}
function updateField(groupName: string, field: string, value: number) {
const updated = { ...props.configData }
ensurePaths(updated)
switch (field) {
case 'homesLimit':
updated.Home.VIPHomesLimits[groupName] = value
break
case 'cooldown':
updated.TPR.VIPCooldowns[groupName] = value
break
case 'countdown':
updated.TPR.VIPCountdowns[groupName] = value
break
case 'dailyLimit':
updated.TPR.VIPDailyLimits[groupName] = value
break
}
emit('update:configData', updated)
}
</script>
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">VIP Permission Groups</h3>
</div>
<!-- Add Group -->
<div class="flex gap-2">
<input
v-model="newGroupName"
placeholder="New group name (e.g. vip, vip+, mvp)..."
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
@keydown.enter="addGroup"
/>
<button
@click="addGroup"
:disabled="!newGroupName.trim()"
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
<Plus class="w-4 h-4" />
Add Group
</button>
</div>
<!-- Empty State -->
<div v-if="groups.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
No VIP groups defined. Add groups to configure per-permission teleport limits, cooldowns, and countdowns.
</div>
<!-- Groups Table -->
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-neutral-800">
<th class="text-left py-3 px-4 text-neutral-500 font-medium">Group Name</th>
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Homes Limit</th>
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Cooldown (s)</th>
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Countdown (s)</th>
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Daily Limit</th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
<tr
v-for="group in groups"
:key="group.name"
class="border-b border-neutral-800/50"
>
<td class="py-3 px-4 text-neutral-200 font-medium">{{ group.name }}</td>
<td class="py-3 px-4">
<input
type="number"
:value="group.homesLimit"
@input="updateField(group.name, 'homesLimit', Number(($event.target as HTMLInputElement).value))"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
min="0"
/>
</td>
<td class="py-3 px-4">
<input
type="number"
:value="group.cooldown"
@input="updateField(group.name, 'cooldown', Number(($event.target as HTMLInputElement).value))"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
min="0"
/>
</td>
<td class="py-3 px-4">
<input
type="number"
:value="group.countdown"
@input="updateField(group.name, 'countdown', Number(($event.target as HTMLInputElement).value))"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
min="0"
/>
</td>
<td class="py-3 px-4">
<input
type="number"
:value="group.dailyLimit"
@input="updateField(group.name, 'dailyLimit', Number(($event.target as HTMLInputElement).value))"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
min="0"
/>
</td>
<td class="py-3 px-4">
<button
@click="removeGroup(group.name)"
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
>
<Trash2 class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
const props = defineProps<{
warps: Record<string, { x: number; y: number; z: number }>
}>()
const emit = defineEmits<{
'update:warps': [warps: Record<string, { x: number; y: number; z: number }>]
}>()
const newWarpName = ref('')
function addWarp() {
const name = newWarpName.value.trim()
if (!name || props.warps[name]) return
const updated = { ...props.warps, [name]: { x: 0, y: 0, z: 0 } }
emit('update:warps', updated)
newWarpName.value = ''
}
function removeWarp(name: string) {
const updated = { ...props.warps }
delete updated[name]
emit('update:warps', updated)
}
</script>
<template>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Warps</h3>
<!-- Add Warp -->
<div class="flex gap-2">
<input
v-model="newWarpName"
placeholder="Warp name..."
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
@keydown.enter="addWarp"
/>
<button
@click="addWarp"
:disabled="!newWarpName.trim()"
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
<Plus class="w-4 h-4" />
Add
</button>
</div>
<!-- Warp List -->
<div v-if="Object.keys(warps).length === 0" class="text-neutral-500 text-sm text-center py-4">
No warps defined. Add warps here and set coordinates in-game.
</div>
<div
v-for="(coords, name) in warps"
:key="name"
class="flex items-center justify-between bg-neutral-800/50 border border-neutral-700/50 rounded-lg px-4 py-3"
>
<div>
<span class="text-neutral-200 font-medium">{{ name }}</span>
<span class="text-neutral-500 text-xs ml-3">
{{ coords.x.toFixed(1) }}, {{ coords.y.toFixed(1) }}, {{ coords.z.toFixed(1) }}
</span>
</div>
<button
@click="removeWarp(name as string)"
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
</template>

View File

@@ -110,11 +110,56 @@ const panelRoutes: RouteRecordRaw[] = [
name: 'files',
component: () => import('@/views/admin/FileManagerView.vue'),
},
{
path: 'plugin-configs',
name: 'plugin-configs',
component: () => import('@/views/admin/PluginConfigsView.vue'),
},
{
path: 'loot-builder',
name: 'loot-builder',
component: () => import('@/views/admin/LootBuilderView.vue'),
},
{
path: 'teleport-config',
name: 'teleport-config',
component: () => import('@/views/admin/TeleportConfigView.vue'),
},
{
path: 'gather-manager',
name: 'gather-manager',
component: () => import('@/views/admin/GatherManagerView.vue'),
},
{
path: 'autodoors',
name: 'autodoors',
component: () => import('@/views/admin/AutoDoorsView.vue'),
},
{
path: 'kits',
name: 'kits-config',
component: () => import('@/views/admin/KitsView.vue'),
},
{
path: 'furnace-splitter',
name: 'furnace-splitter',
component: () => import('@/views/admin/FurnaceSplitterView.vue'),
},
{
path: 'better-chat',
name: 'better-chat',
component: () => import('@/views/admin/BetterChatView.vue'),
},
{
path: 'timed-execute',
name: 'timed-execute',
component: () => import('@/views/admin/TimedExecuteView.vue'),
},
{
path: 'raidable-bases',
name: 'raidable-bases',
component: () => import('@/views/admin/RaidableBasesView.vue'),
},
{
path: 'wipes',
name: 'wipes',

View File

@@ -0,0 +1,145 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import type { AutoDoorsConfigSummary, AutoDoorsConfigFull, AutoDoorsApplyResult } from '@/types'
export const useAutoDoorsStore = defineStore('autodoors', () => {
const configs = ref<AutoDoorsConfigSummary[]>([])
const currentConfig = ref<AutoDoorsConfigFull | null>(null)
const isLoading = ref(false)
const isSaving = ref(false)
const isApplying = ref(false)
const isDirty = ref(false)
const api = useApi()
const toast = useToastStore()
async function fetchConfigs() {
isLoading.value = true
try {
const res = await api.get<{ configs: AutoDoorsConfigSummary[] }>('/autodoors/configs')
configs.value = res.configs
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function loadConfig(id: string) {
isLoading.value = true
try {
const res = await api.get<{ config: AutoDoorsConfigFull }>(`/autodoors/configs/${id}`)
currentConfig.value = res.config
isDirty.value = false
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function createConfig(name: string, description?: string) {
try {
const res = await api.post<{ config: AutoDoorsConfigFull }>('/autodoors/configs', {
config_name: name,
description,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config "${name}" created`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function saveCurrentConfig() {
if (!currentConfig.value) return
isSaving.value = true
try {
await api.put(`/autodoors/configs/${currentConfig.value.id}`, {
config_name: currentConfig.value.config_name,
description: currentConfig.value.description,
config_data: currentConfig.value.config_data,
})
isDirty.value = false
await fetchConfigs()
toast.success('Config saved')
} catch (err) {
toast.error((err as Error).message)
} finally {
isSaving.value = false
}
}
async function deleteConfig(id: string) {
try {
await api.del(`/autodoors/configs/${id}`)
if (currentConfig.value?.id === id) {
currentConfig.value = null
}
await fetchConfigs()
toast.success('Config deleted')
} catch (err) {
toast.error((err as Error).message)
}
}
async function applyToServer(id: string) {
isApplying.value = true
try {
const res = await api.post<AutoDoorsApplyResult>(`/autodoors/configs/${id}/apply`)
await fetchConfigs()
toast.success(res.message)
return res
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isApplying.value = false
}
}
async function importFromServer(configName: string) {
isLoading.value = true
try {
const res = await api.post<{ config: AutoDoorsConfigFull }>('/autodoors/import-from-server', {
config_name: configName,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config imported from server as "${configName}"`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isLoading.value = false
}
}
function markDirty() {
isDirty.value = true
}
return {
configs,
currentConfig,
isLoading,
isSaving,
isApplying,
isDirty,
fetchConfigs,
loadConfig,
createConfig,
saveCurrentConfig,
deleteConfig,
applyToServer,
importFromServer,
markDirty,
}
})

View File

@@ -0,0 +1,145 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import type { BetterChatConfigSummary, BetterChatConfigFull, BetterChatApplyResult } from '@/types'
export const useBetterChatStore = defineStore('betterchat', () => {
const configs = ref<BetterChatConfigSummary[]>([])
const currentConfig = ref<BetterChatConfigFull | null>(null)
const isLoading = ref(false)
const isSaving = ref(false)
const isApplying = ref(false)
const isDirty = ref(false)
const api = useApi()
const toast = useToastStore()
async function fetchConfigs() {
isLoading.value = true
try {
const res = await api.get<{ configs: BetterChatConfigSummary[] }>('/betterchat/configs')
configs.value = res.configs
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function loadConfig(id: string) {
isLoading.value = true
try {
const res = await api.get<{ config: BetterChatConfigFull }>(`/betterchat/configs/${id}`)
currentConfig.value = res.config
isDirty.value = false
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function createConfig(name: string, description?: string) {
try {
const res = await api.post<{ config: BetterChatConfigFull }>('/betterchat/configs', {
config_name: name,
description,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config "${name}" created`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function saveCurrentConfig() {
if (!currentConfig.value) return
isSaving.value = true
try {
await api.put(`/betterchat/configs/${currentConfig.value.id}`, {
config_name: currentConfig.value.config_name,
description: currentConfig.value.description,
config_data: currentConfig.value.config_data,
})
isDirty.value = false
await fetchConfigs()
toast.success('Config saved')
} catch (err) {
toast.error((err as Error).message)
} finally {
isSaving.value = false
}
}
async function deleteConfig(id: string) {
try {
await api.del(`/betterchat/configs/${id}`)
if (currentConfig.value?.id === id) {
currentConfig.value = null
}
await fetchConfigs()
toast.success('Config deleted')
} catch (err) {
toast.error((err as Error).message)
}
}
async function applyToServer(id: string) {
isApplying.value = true
try {
const res = await api.post<BetterChatApplyResult>(`/betterchat/configs/${id}/apply`)
await fetchConfigs()
toast.success(res.message)
return res
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isApplying.value = false
}
}
async function importFromServer(configName: string) {
isLoading.value = true
try {
const res = await api.post<{ config: BetterChatConfigFull }>('/betterchat/import-from-server', {
config_name: configName,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config imported from server as "${configName}"`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isLoading.value = false
}
}
function markDirty() {
isDirty.value = true
}
return {
configs,
currentConfig,
isLoading,
isSaving,
isApplying,
isDirty,
fetchConfigs,
loadConfig,
createConfig,
saveCurrentConfig,
deleteConfig,
applyToServer,
importFromServer,
markDirty,
}
})

View File

@@ -0,0 +1,145 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import type { FurnaceSplitterConfigSummary, FurnaceSplitterConfigFull, FurnaceSplitterApplyResult } from '@/types'
export const useFurnaceSplitterStore = defineStore('furnacesplitter', () => {
const configs = ref<FurnaceSplitterConfigSummary[]>([])
const currentConfig = ref<FurnaceSplitterConfigFull | null>(null)
const isLoading = ref(false)
const isSaving = ref(false)
const isApplying = ref(false)
const isDirty = ref(false)
const api = useApi()
const toast = useToastStore()
async function fetchConfigs() {
isLoading.value = true
try {
const res = await api.get<{ configs: FurnaceSplitterConfigSummary[] }>('/furnacesplitter/configs')
configs.value = res.configs
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function loadConfig(id: string) {
isLoading.value = true
try {
const res = await api.get<{ config: FurnaceSplitterConfigFull }>(`/furnacesplitter/configs/${id}`)
currentConfig.value = res.config
isDirty.value = false
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function createConfig(name: string, description?: string) {
try {
const res = await api.post<{ config: FurnaceSplitterConfigFull }>('/furnacesplitter/configs', {
config_name: name,
description,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config "${name}" created`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function saveCurrentConfig() {
if (!currentConfig.value) return
isSaving.value = true
try {
await api.put(`/furnacesplitter/configs/${currentConfig.value.id}`, {
config_name: currentConfig.value.config_name,
description: currentConfig.value.description,
config_data: currentConfig.value.config_data,
})
isDirty.value = false
await fetchConfigs()
toast.success('Config saved')
} catch (err) {
toast.error((err as Error).message)
} finally {
isSaving.value = false
}
}
async function deleteConfig(id: string) {
try {
await api.del(`/furnacesplitter/configs/${id}`)
if (currentConfig.value?.id === id) {
currentConfig.value = null
}
await fetchConfigs()
toast.success('Config deleted')
} catch (err) {
toast.error((err as Error).message)
}
}
async function applyToServer(id: string) {
isApplying.value = true
try {
const res = await api.post<FurnaceSplitterApplyResult>(`/furnacesplitter/configs/${id}/apply`)
await fetchConfigs()
toast.success(res.message)
return res
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isApplying.value = false
}
}
async function importFromServer(configName: string) {
isLoading.value = true
try {
const res = await api.post<{ config: FurnaceSplitterConfigFull }>('/furnacesplitter/import-from-server', {
config_name: configName,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config imported from server as "${configName}"`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isLoading.value = false
}
}
function markDirty() {
isDirty.value = true
}
return {
configs,
currentConfig,
isLoading,
isSaving,
isApplying,
isDirty,
fetchConfigs,
loadConfig,
createConfig,
saveCurrentConfig,
deleteConfig,
applyToServer,
importFromServer,
markDirty,
}
})

View File

@@ -0,0 +1,145 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import type { GatherConfigSummary, GatherConfigFull, GatherApplyResult } from '@/types'
export const useGatherStore = defineStore('gather', () => {
const configs = ref<GatherConfigSummary[]>([])
const currentConfig = ref<GatherConfigFull | null>(null)
const isLoading = ref(false)
const isSaving = ref(false)
const isApplying = ref(false)
const isDirty = ref(false)
const api = useApi()
const toast = useToastStore()
async function fetchConfigs() {
isLoading.value = true
try {
const res = await api.get<{ configs: GatherConfigSummary[] }>('/gather/configs')
configs.value = res.configs
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function loadConfig(id: string) {
isLoading.value = true
try {
const res = await api.get<{ config: GatherConfigFull }>(`/gather/configs/${id}`)
currentConfig.value = res.config
isDirty.value = false
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function createConfig(name: string, description?: string) {
try {
const res = await api.post<{ config: GatherConfigFull }>('/gather/configs', {
config_name: name,
description,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config "${name}" created`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function saveCurrentConfig() {
if (!currentConfig.value) return
isSaving.value = true
try {
await api.put(`/gather/configs/${currentConfig.value.id}`, {
config_name: currentConfig.value.config_name,
description: currentConfig.value.description,
config_data: currentConfig.value.config_data,
})
isDirty.value = false
await fetchConfigs()
toast.success('Config saved')
} catch (err) {
toast.error((err as Error).message)
} finally {
isSaving.value = false
}
}
async function deleteConfig(id: string) {
try {
await api.del(`/gather/configs/${id}`)
if (currentConfig.value?.id === id) {
currentConfig.value = null
}
await fetchConfigs()
toast.success('Config deleted')
} catch (err) {
toast.error((err as Error).message)
}
}
async function applyToServer(id: string) {
isApplying.value = true
try {
const res = await api.post<GatherApplyResult>(`/gather/configs/${id}/apply`)
await fetchConfigs()
toast.success(res.message)
return res
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isApplying.value = false
}
}
async function importFromServer(configName: string) {
isLoading.value = true
try {
const res = await api.post<{ config: GatherConfigFull }>('/gather/import-from-server', {
config_name: configName,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config imported from server as "${configName}"`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isLoading.value = false
}
}
function markDirty() {
isDirty.value = true
}
return {
configs,
currentConfig,
isLoading,
isSaving,
isApplying,
isDirty,
fetchConfigs,
loadConfig,
createConfig,
saveCurrentConfig,
deleteConfig,
applyToServer,
importFromServer,
markDirty,
}
})

145
frontend/src/stores/kits.ts Normal file
View File

@@ -0,0 +1,145 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import type { KitsConfigSummary, KitsConfigFull, KitsApplyResult } from '@/types'
export const useKitsStore = defineStore('kits', () => {
const configs = ref<KitsConfigSummary[]>([])
const currentConfig = ref<KitsConfigFull | null>(null)
const isLoading = ref(false)
const isSaving = ref(false)
const isApplying = ref(false)
const isDirty = ref(false)
const api = useApi()
const toast = useToastStore()
async function fetchConfigs() {
isLoading.value = true
try {
const res = await api.get<{ configs: KitsConfigSummary[] }>('/kits/configs')
configs.value = res.configs
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function loadConfig(id: string) {
isLoading.value = true
try {
const res = await api.get<{ config: KitsConfigFull }>(`/kits/configs/${id}`)
currentConfig.value = res.config
isDirty.value = false
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function createConfig(name: string, description?: string) {
try {
const res = await api.post<{ config: KitsConfigFull }>('/kits/configs', {
config_name: name,
description,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config "${name}" created`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function saveCurrentConfig() {
if (!currentConfig.value) return
isSaving.value = true
try {
await api.put(`/kits/configs/${currentConfig.value.id}`, {
config_name: currentConfig.value.config_name,
description: currentConfig.value.description,
config_data: currentConfig.value.config_data,
})
isDirty.value = false
await fetchConfigs()
toast.success('Config saved')
} catch (err) {
toast.error((err as Error).message)
} finally {
isSaving.value = false
}
}
async function deleteConfig(id: string) {
try {
await api.del(`/kits/configs/${id}`)
if (currentConfig.value?.id === id) {
currentConfig.value = null
}
await fetchConfigs()
toast.success('Config deleted')
} catch (err) {
toast.error((err as Error).message)
}
}
async function applyToServer(id: string) {
isApplying.value = true
try {
const res = await api.post<KitsApplyResult>(`/kits/configs/${id}/apply`)
await fetchConfigs()
toast.success(res.message)
return res
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isApplying.value = false
}
}
async function importFromServer(configName: string) {
isLoading.value = true
try {
const res = await api.post<{ config: KitsConfigFull }>('/kits/import-from-server', {
config_name: configName,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config imported from server as "${configName}"`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isLoading.value = false
}
}
function markDirty() {
isDirty.value = true
}
return {
configs,
currentConfig,
isLoading,
isSaving,
isApplying,
isDirty,
fetchConfigs,
loadConfig,
createConfig,
saveCurrentConfig,
deleteConfig,
applyToServer,
importFromServer,
markDirty,
}
})

View File

@@ -0,0 +1,145 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import type { RaidableBasesConfigSummary, RaidableBasesConfigFull, RaidableBasesApplyResult } from '@/types'
export const useRaidableBasesStore = defineStore('raidablebases', () => {
const configs = ref<RaidableBasesConfigSummary[]>([])
const currentConfig = ref<RaidableBasesConfigFull | null>(null)
const isLoading = ref(false)
const isSaving = ref(false)
const isApplying = ref(false)
const isDirty = ref(false)
const api = useApi()
const toast = useToastStore()
async function fetchConfigs() {
isLoading.value = true
try {
const res = await api.get<{ configs: RaidableBasesConfigSummary[] }>('/raidablebases/configs')
configs.value = res.configs
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function loadConfig(id: string) {
isLoading.value = true
try {
const res = await api.get<{ config: RaidableBasesConfigFull }>(`/raidablebases/configs/${id}`)
currentConfig.value = res.config
isDirty.value = false
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function createConfig(name: string, description?: string) {
try {
const res = await api.post<{ config: RaidableBasesConfigFull }>('/raidablebases/configs', {
config_name: name,
description,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config "${name}" created`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function saveCurrentConfig() {
if (!currentConfig.value) return
isSaving.value = true
try {
await api.put(`/raidablebases/configs/${currentConfig.value.id}`, {
config_name: currentConfig.value.config_name,
description: currentConfig.value.description,
config_data: currentConfig.value.config_data,
})
isDirty.value = false
await fetchConfigs()
toast.success('Config saved')
} catch (err) {
toast.error((err as Error).message)
} finally {
isSaving.value = false
}
}
async function deleteConfig(id: string) {
try {
await api.del(`/raidablebases/configs/${id}`)
if (currentConfig.value?.id === id) {
currentConfig.value = null
}
await fetchConfigs()
toast.success('Config deleted')
} catch (err) {
toast.error((err as Error).message)
}
}
async function applyToServer(id: string) {
isApplying.value = true
try {
const res = await api.post<RaidableBasesApplyResult>(`/raidablebases/configs/${id}/apply`)
await fetchConfigs()
toast.success(res.message)
return res
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isApplying.value = false
}
}
async function importFromServer(configName: string) {
isLoading.value = true
try {
const res = await api.post<{ config: RaidableBasesConfigFull }>('/raidablebases/import-from-server', {
config_name: configName,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config imported from server as "${configName}"`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isLoading.value = false
}
}
function markDirty() {
isDirty.value = true
}
return {
configs,
currentConfig,
isLoading,
isSaving,
isApplying,
isDirty,
fetchConfigs,
loadConfig,
createConfig,
saveCurrentConfig,
deleteConfig,
applyToServer,
importFromServer,
markDirty,
}
})

View File

@@ -64,6 +64,15 @@ export const useServerStore = defineStore('server', () => {
}
}
async function installOxide() {
try {
await api.post('/servers/install-oxide')
} catch (e) {
console.error('Failed to start Oxide installation:', e)
throw e
}
}
function updateDeploymentStatus(status: DeploymentStatus) {
deploymentStatus.value = status
if (status.stage === 'online' || status.stage === 'failed') {
@@ -94,6 +103,7 @@ export const useServerStore = defineStore('server', () => {
stopServer,
restartServer,
deployServer,
installOxide,
updateDeploymentStatus,
clearDeploymentStatus,
updateStats,

View File

@@ -0,0 +1,145 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import type { TeleportConfigSummary, TeleportConfigFull, TeleportApplyResult } from '@/types'
export const useTeleportStore = defineStore('teleport', () => {
const configs = ref<TeleportConfigSummary[]>([])
const currentConfig = ref<TeleportConfigFull | null>(null)
const isLoading = ref(false)
const isSaving = ref(false)
const isApplying = ref(false)
const isDirty = ref(false)
const api = useApi()
const toast = useToastStore()
async function fetchConfigs() {
isLoading.value = true
try {
const res = await api.get<{ configs: TeleportConfigSummary[] }>('/teleport/configs')
configs.value = res.configs
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function loadConfig(id: string) {
isLoading.value = true
try {
const res = await api.get<{ config: TeleportConfigFull }>(`/teleport/configs/${id}`)
currentConfig.value = res.config
isDirty.value = false
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function createConfig(name: string, description?: string) {
try {
const res = await api.post<{ config: TeleportConfigFull }>('/teleport/configs', {
config_name: name,
description,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config "${name}" created`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function saveCurrentConfig() {
if (!currentConfig.value) return
isSaving.value = true
try {
await api.put(`/teleport/configs/${currentConfig.value.id}`, {
config_name: currentConfig.value.config_name,
description: currentConfig.value.description,
config_data: currentConfig.value.config_data,
})
isDirty.value = false
await fetchConfigs()
toast.success('Config saved')
} catch (err) {
toast.error((err as Error).message)
} finally {
isSaving.value = false
}
}
async function deleteConfig(id: string) {
try {
await api.del(`/teleport/configs/${id}`)
if (currentConfig.value?.id === id) {
currentConfig.value = null
}
await fetchConfigs()
toast.success('Config deleted')
} catch (err) {
toast.error((err as Error).message)
}
}
async function applyToServer(id: string) {
isApplying.value = true
try {
const res = await api.post<TeleportApplyResult>(`/teleport/configs/${id}/apply`)
await fetchConfigs()
toast.success(res.message)
return res
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isApplying.value = false
}
}
async function importFromServer(configName: string) {
isLoading.value = true
try {
const res = await api.post<{ config: TeleportConfigFull }>('/teleport/import-from-server', {
config_name: configName,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config imported from server as "${configName}"`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isLoading.value = false
}
}
function markDirty() {
isDirty.value = true
}
return {
configs,
currentConfig,
isLoading,
isSaving,
isApplying,
isDirty,
fetchConfigs,
loadConfig,
createConfig,
saveCurrentConfig,
deleteConfig,
applyToServer,
importFromServer,
markDirty,
}
})

View File

@@ -0,0 +1,145 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import type { TimedExecuteConfigSummary, TimedExecuteConfigFull, TimedExecuteApplyResult } from '@/types'
export const useTimedExecuteStore = defineStore('timedexecute', () => {
const configs = ref<TimedExecuteConfigSummary[]>([])
const currentConfig = ref<TimedExecuteConfigFull | null>(null)
const isLoading = ref(false)
const isSaving = ref(false)
const isApplying = ref(false)
const isDirty = ref(false)
const api = useApi()
const toast = useToastStore()
async function fetchConfigs() {
isLoading.value = true
try {
const res = await api.get<{ configs: TimedExecuteConfigSummary[] }>('/timedexecute/configs')
configs.value = res.configs
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function loadConfig(id: string) {
isLoading.value = true
try {
const res = await api.get<{ config: TimedExecuteConfigFull }>(`/timedexecute/configs/${id}`)
currentConfig.value = res.config
isDirty.value = false
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function createConfig(name: string, description?: string) {
try {
const res = await api.post<{ config: TimedExecuteConfigFull }>('/timedexecute/configs', {
config_name: name,
description,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config "${name}" created`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function saveCurrentConfig() {
if (!currentConfig.value) return
isSaving.value = true
try {
await api.put(`/timedexecute/configs/${currentConfig.value.id}`, {
config_name: currentConfig.value.config_name,
description: currentConfig.value.description,
config_data: currentConfig.value.config_data,
})
isDirty.value = false
await fetchConfigs()
toast.success('Config saved')
} catch (err) {
toast.error((err as Error).message)
} finally {
isSaving.value = false
}
}
async function deleteConfig(id: string) {
try {
await api.del(`/timedexecute/configs/${id}`)
if (currentConfig.value?.id === id) {
currentConfig.value = null
}
await fetchConfigs()
toast.success('Config deleted')
} catch (err) {
toast.error((err as Error).message)
}
}
async function applyToServer(id: string) {
isApplying.value = true
try {
const res = await api.post<TimedExecuteApplyResult>(`/timedexecute/configs/${id}/apply`)
await fetchConfigs()
toast.success(res.message)
return res
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isApplying.value = false
}
}
async function importFromServer(configName: string) {
isLoading.value = true
try {
const res = await api.post<{ config: TimedExecuteConfigFull }>('/timedexecute/import-from-server', {
config_name: configName,
})
await fetchConfigs()
currentConfig.value = res.config
isDirty.value = false
toast.success(`Config imported from server as "${configName}"`)
return res.config
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isLoading.value = false
}
}
function markDirty() {
isDirty.value = true
}
return {
configs,
currentConfig,
isLoading,
isSaving,
isApplying,
isDirty,
fetchConfigs,
loadConfig,
createConfig,
saveCurrentConfig,
deleteConfig,
applyToServer,
importFromServer,
markDirty,
}
})

View File

@@ -519,3 +519,219 @@ export interface LootApplyResult {
profile_name: string
multiplier: number
}
// Teleport Config types — NTeleportation integration
export interface TeleportConfigSummary {
id: string
config_name: string
description: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface TeleportConfigFull {
id: string
license_id: string
config_name: string
description: string | null
config_data: Record<string, any>
is_active: boolean
created_at: string
updated_at: string
}
export interface TeleportApplyResult {
success: boolean
message: string
config_name: string
}
// GatherManager Config types
export interface GatherConfigSummary {
id: string
config_name: string
description: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface GatherConfigFull {
id: string
license_id: string
config_name: string
description: string | null
config_data: Record<string, any>
is_active: boolean
created_at: string
updated_at: string
}
export interface GatherApplyResult {
success: boolean
message: string
config_name: string
}
// AutoDoors Config types
export interface AutoDoorsConfigSummary {
id: string
config_name: string
description: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface AutoDoorsConfigFull {
id: string
license_id: string
config_name: string
description: string | null
config_data: Record<string, any>
is_active: boolean
created_at: string
updated_at: string
}
export interface AutoDoorsApplyResult {
success: boolean
message: string
config_name: string
}
// Kits Config types
export interface KitsConfigSummary {
id: string
config_name: string
description: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface KitsConfigFull {
id: string
license_id: string
config_name: string
description: string | null
config_data: Record<string, any>
is_active: boolean
created_at: string
updated_at: string
}
export interface KitsApplyResult {
success: boolean
message: string
config_name: string
}
// FurnaceSplitter Config types
export interface FurnaceSplitterConfigSummary {
id: string
config_name: string
description: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface FurnaceSplitterConfigFull {
id: string
license_id: string
config_name: string
description: string | null
config_data: Record<string, any>
is_active: boolean
created_at: string
updated_at: string
}
export interface FurnaceSplitterApplyResult {
success: boolean
message: string
config_name: string
}
// BetterChat Config types
export interface BetterChatConfigSummary {
id: string
config_name: string
description: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface BetterChatConfigFull {
id: string
license_id: string
config_name: string
description: string | null
config_data: Record<string, any>
is_active: boolean
created_at: string
updated_at: string
}
export interface BetterChatApplyResult {
success: boolean
message: string
config_name: string
}
// TimedExecute Config types
export interface TimedExecuteConfigSummary {
id: string
config_name: string
description: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface TimedExecuteConfigFull {
id: string
license_id: string
config_name: string
description: string | null
config_data: Record<string, any>
is_active: boolean
created_at: string
updated_at: string
}
export interface TimedExecuteApplyResult {
success: boolean
message: string
config_name: string
}
// RaidableBases Config types
export interface RaidableBasesConfigSummary {
id: string
config_name: string
description: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface RaidableBasesConfigFull {
id: string
license_id: string
config_name: string
description: string | null
config_data: Record<string, any>
is_active: boolean
created_at: string
updated_at: string
}
export interface RaidableBasesApplyResult {
success: boolean
message: string
config_name: string
}

View File

@@ -0,0 +1,595 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAutoDoorsStore } from '@/stores/autodoors'
import {
Save,
Play,
Download,
Plus,
Trash2,
DoorOpen,
Settings as SettingsIcon,
} from 'lucide-vue-next'
const store = useAutoDoorsStore()
const showCreateModal = ref(false)
const showImportModal = ref(false)
const newConfigName = ref('')
const newConfigDesc = ref('')
const importConfigName = ref('')
// Door types from the AutoDoors plugin
const doorTypes = [
{ key: 'door.hinged.wood', label: 'Wooden Door', displayName: 'Wooden Door' },
{ key: 'door.hinged.metal', label: 'Sheet Metal Door', displayName: 'Sheet Metal Door' },
{ key: 'door.hinged.toptier', label: 'Armored Door', displayName: 'Armored Door' },
{ key: 'door.double.hinged.wood', label: 'Double Wooden Door', displayName: 'Double Wooden Door' },
{ key: 'door.double.hinged.metal', label: 'Double Sheet Metal Door', displayName: 'Double Sheet Metal Door' },
{ key: 'door.double.hinged.toptier', label: 'Double Armored Door', displayName: 'Double Armored Door' },
{ key: 'floor.ladder.hatch', label: 'Ladder Hatch', displayName: 'Ladder Hatch' },
]
onMounted(async () => {
await store.fetchConfigs()
if (store.configs.length > 0 && store.configs[0]) {
await store.loadConfig(store.configs[0].id)
}
})
// --- Config data helpers ---
function getConfigValue(path: string, defaultValue: any = false): any {
if (!store.currentConfig?.config_data) return defaultValue
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (const part of parts) {
if (current == null || typeof current !== 'object') return defaultValue
current = current[part]
}
return current ?? defaultValue
}
function setConfigValue(path: string, value: any) {
if (!store.currentConfig) return
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!
if (current[part] == null || typeof current[part] !== 'object') {
current[part] = {}
}
current = current[part]
}
current[parts[parts.length - 1]!] = value
store.markDirty()
}
// --- Permission group helpers ---
function getPermissionGroups(): Array<{ name: string; delay: number }> {
const groups = getConfigValue('PermissionGroups', {})
if (typeof groups !== 'object' || groups === null) return []
return Object.entries(groups).map(([name, delay]) => ({
name,
delay: Number(delay) || 5,
}))
}
function addPermissionGroup() {
const groups = getConfigValue('PermissionGroups', {})
const newGroups = { ...groups, '': 5 }
setConfigValue('PermissionGroups', newGroups)
}
function updatePermissionGroupName(oldName: string, newName: string) {
if (!store.currentConfig) return
const groups = getConfigValue('PermissionGroups', {})
const delay = groups[oldName] ?? 5
const newGroups: Record<string, number> = {}
for (const [key, val] of Object.entries(groups)) {
if (key === oldName) {
newGroups[newName] = delay
} else {
newGroups[key] = val as number
}
}
setConfigValue('PermissionGroups', newGroups)
}
function updatePermissionGroupDelay(name: string, delay: number) {
setConfigValue(`PermissionGroups.${name}`, delay)
}
function removePermissionGroup(name: string) {
const groups = getConfigValue('PermissionGroups', {})
const newGroups = { ...groups }
delete newGroups[name]
setConfigValue('PermissionGroups', newGroups)
}
// --- Action handlers ---
async function handleConfigChange(id: string) {
if (store.isDirty) {
if (!confirm('Unsaved changes will be lost. Continue?')) return
}
await store.loadConfig(id)
}
async function handleCreateConfig() {
if (!newConfigName.value.trim()) return
const config = await store.createConfig(
newConfigName.value.trim(),
newConfigDesc.value.trim() || undefined,
)
if (config) {
showCreateModal.value = false
newConfigName.value = ''
newConfigDesc.value = ''
}
}
async function handleDeleteConfig() {
if (!store.currentConfig) return
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
await store.deleteConfig(store.currentConfig.id)
}
async function handleApply() {
if (!store.currentConfig) return
if (!confirm('Apply this AutoDoors config to the server? This will overwrite the current AutoDoors config.')) return
if (store.isDirty) {
await store.saveCurrentConfig()
}
await store.applyToServer(store.currentConfig.id)
}
async function handleImport() {
if (!importConfigName.value.trim()) return
const config = await store.importFromServer(importConfigName.value.trim())
if (config) {
showImportModal.value = false
importConfigName.value = ''
}
}
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">Auto Doors</h1>
<div class="flex items-center gap-3">
<button
@click="showCreateModal = true"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Plus class="w-4 h-4" />
New Config
</button>
</div>
</div>
<!-- Config Selector + Action Bar -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div class="flex items-center gap-3 flex-wrap">
<!-- Config Selector -->
<select
v-if="store.configs.length > 0"
:value="store.currentConfig?.id || ''"
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
>
<option v-for="c in store.configs" :key="c.id" :value="c.id">
{{ c.config_name }}
<template v-if="c.is_active"> (Active)</template>
</option>
</select>
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
<!-- Save -->
<button
@click="store.saveCurrentConfig()"
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Save class="w-4 h-4" />
{{ store.isSaving ? 'Saving...' : 'Save' }}
</button>
<!-- Apply to Server -->
<button
@click="handleApply"
:disabled="!store.currentConfig || store.isApplying"
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Play class="w-4 h-4" />
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
</button>
<!-- Import from Server -->
<button
@click="showImportModal = true"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Download class="w-4 h-4" />
Import from Server
</button>
<!-- Delete -->
<button
@click="handleDeleteConfig"
:disabled="!store.currentConfig"
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
>
<Trash2 class="w-4 h-4" />
Delete
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
</div>
<!-- No Config Selected -->
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
<DoorOpen class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No AutoDoors Config Selected</h2>
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
<button
@click="showCreateModal = true"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
>
Create First Config
</button>
</div>
<!-- Config Editor -->
<div v-else class="space-y-6">
<!-- Settings Section -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
<div class="flex items-center gap-2">
<SettingsIcon class="w-4 h-4 text-neutral-400" />
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Global Settings</h3>
</div>
<!-- Delay Settings -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label class="block text-sm text-neutral-200 mb-1">Default Delay (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">Time before door auto-closes</p>
<div class="flex items-center gap-3">
<input
type="range"
:value="getConfigValue('DefaultDelay', 5)"
@input="setConfigValue('DefaultDelay', Number(($event.target as HTMLInputElement).value))"
min="1"
max="30"
step="1"
class="flex-1 accent-oxide-500"
/>
<input
type="number"
:value="getConfigValue('DefaultDelay', 5)"
@input="setConfigValue('DefaultDelay', Number(($event.target as HTMLInputElement).value))"
min="1"
max="30"
class="w-16 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
/>
<span class="text-xs text-neutral-500">sec</span>
</div>
</div>
<div>
<label class="block text-sm text-neutral-200 mb-1">Minimum Delay (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">Lowest delay a player can set</p>
<div class="flex items-center gap-3">
<input
type="range"
:value="getConfigValue('MinimumDelay', 5)"
@input="setConfigValue('MinimumDelay', Number(($event.target as HTMLInputElement).value))"
min="1"
max="30"
step="1"
class="flex-1 accent-oxide-500"
/>
<input
type="number"
:value="getConfigValue('MinimumDelay', 5)"
@input="setConfigValue('MinimumDelay', Number(($event.target as HTMLInputElement).value))"
min="1"
max="30"
class="w-16 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
/>
<span class="text-xs text-neutral-500">sec</span>
</div>
</div>
<div>
<label class="block text-sm text-neutral-200 mb-1">Maximum Delay (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">Highest delay a player can set</p>
<div class="flex items-center gap-3">
<input
type="range"
:value="getConfigValue('MaximumDelay', 30)"
@input="setConfigValue('MaximumDelay', Number(($event.target as HTMLInputElement).value))"
min="1"
max="60"
step="1"
class="flex-1 accent-oxide-500"
/>
<input
type="number"
:value="getConfigValue('MaximumDelay', 30)"
@input="setConfigValue('MaximumDelay', Number(($event.target as HTMLInputElement).value))"
min="1"
max="60"
class="w-16 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
/>
<span class="text-xs text-neutral-500">sec</span>
</div>
</div>
</div>
<!-- Global Toggles -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Default Enabled</label>
<p class="text-xs text-neutral-500">Auto-close enabled for new players by default</p>
</div>
<button
@click="setConfigValue('GlobalSettings.defaultEnabled', !getConfigValue('GlobalSettings.defaultEnabled', true))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('GlobalSettings.defaultEnabled', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('GlobalSettings.defaultEnabled', true) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Allow Unowned Doors</label>
<p class="text-xs text-neutral-500">Auto-close doors that the player does not own</p>
</div>
<button
@click="setConfigValue('GlobalSettings.useUnownedDoor', !getConfigValue('GlobalSettings.useUnownedDoor', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('GlobalSettings.useUnownedDoor', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('GlobalSettings.useUnownedDoor', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Exclude Door Controller</label>
<p class="text-xs text-neutral-500">Skip doors that have a Code Lock or Key Lock</p>
</div>
<button
@click="setConfigValue('GlobalSettings.excludeDoorController', !getConfigValue('GlobalSettings.excludeDoorController', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('GlobalSettings.excludeDoorController', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('GlobalSettings.excludeDoorController', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Cancel on Player Death</label>
<p class="text-xs text-neutral-500">Cancel auto-close if the player dies</p>
</div>
<button
@click="setConfigValue('GlobalSettings.cancelOnKill', !getConfigValue('GlobalSettings.cancelOnKill', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('GlobalSettings.cancelOnKill', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('GlobalSettings.cancelOnKill', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Use Permissions</label>
<p class="text-xs text-neutral-500">Require Oxide permission to use auto-close</p>
</div>
<button
@click="setConfigValue('UsePermissions', !getConfigValue('UsePermissions', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('UsePermissions', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('UsePermissions', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Clear Data on Map Wipe</label>
<p class="text-xs text-neutral-500">Reset all player preferences on map wipe</p>
</div>
<button
@click="setConfigValue('ClearDataOnWipe', !getConfigValue('ClearDataOnWipe', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('ClearDataOnWipe', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('ClearDataOnWipe', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
</div>
<!-- Door Types Section -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<div class="flex items-center gap-2">
<DoorOpen class="w-4 h-4 text-neutral-400" />
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Door Types</h3>
</div>
<p class="text-xs text-neutral-500">Enable or disable auto-close for each door type.</p>
<div class="space-y-3">
<div
v-for="door in doorTypes"
:key="door.key"
class="flex items-center justify-between py-2 border-b border-neutral-800 last:border-0"
>
<div>
<label class="text-sm text-neutral-200">{{ door.label }}</label>
<p class="text-xs text-neutral-500 font-mono">{{ door.key }}</p>
</div>
<button
@click="setConfigValue(`DoorSettings.${door.key}.enabled`, !getConfigValue(`DoorSettings.${door.key}.enabled`, true))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue(`DoorSettings.${door.key}.enabled`, true) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue(`DoorSettings.${door.key}.enabled`, true) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
</div>
<!-- Permission Groups Section -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<SettingsIcon class="w-4 h-4 text-neutral-400" />
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Permission Group Overrides</h3>
</div>
<button
@click="addPermissionGroup"
class="flex items-center gap-1 px-3 py-1 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Plus class="w-3 h-3" />
Add Group
</button>
</div>
<p class="text-xs text-neutral-500">Override the default delay for specific Oxide permission groups.</p>
<div v-if="getPermissionGroups().length === 0" class="text-sm text-neutral-500 text-center py-4">
No permission group overrides configured.
</div>
<div v-else class="space-y-3">
<div
v-for="(group, index) in getPermissionGroups()"
:key="index"
class="flex items-center gap-3"
>
<input
:value="group.name"
@input="updatePermissionGroupName(group.name, ($event.target as HTMLInputElement).value)"
placeholder="Group name (e.g. vip)"
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
<input
type="number"
:value="group.delay"
@input="updatePermissionGroupDelay(group.name, Number(($event.target as HTMLInputElement).value))"
min="1"
max="60"
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-2 text-neutral-200 text-sm text-center"
/>
<span class="text-xs text-neutral-500">sec</span>
<button
@click="removePermissionGroup(group.name)"
class="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
<!-- Create Config Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New AutoDoors Config</h2>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="newConfigName"
placeholder="e.g. 5 Second Close"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleCreateConfig"
/>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
<textarea
v-model="newConfigDesc"
rows="2"
placeholder="What is this config for?"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
/>
</div>
<div class="flex justify-end gap-2">
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="handleCreateConfig"
:disabled="!newConfigName.trim()"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
Create
</button>
</div>
</div>
</div>
</div>
<!-- Import from Server Modal -->
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
<p class="text-sm text-neutral-400 mb-4">
Import the current AutoDoors config from your live server. This will create a new config profile.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="importConfigName"
placeholder="e.g. Imported Server Config"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleImport"
/>
</div>
<div class="flex justify-end gap-2">
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="handleImport"
:disabled="!importConfigName.trim()"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
Import
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,790 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useBetterChatStore } from '@/stores/betterchat'
import {
Save,
Play,
Download,
Plus,
Trash2,
MessageSquare,
Settings as SettingsIcon,
Edit,
X,
} from 'lucide-vue-next'
const store = useBetterChatStore()
const activeTab = ref<'groups' | 'settings'>('groups')
const showCreateModal = ref(false)
const showImportModal = ref(false)
const showGroupModal = ref(false)
const newConfigName = ref('')
const newConfigDesc = ref('')
const importConfigName = ref('')
const editingGroupIndex = ref<number | null>(null)
const tabs = [
{ key: 'groups', label: 'Chat Groups', icon: MessageSquare },
{ key: 'settings', label: 'Settings', icon: SettingsIcon },
]
// Default group template matching BetterChat actual format
const defaultGroup = {
GroupName: 'newgroup',
Priority: 0,
Title: {
Text: '[Player]',
Color: '#55aaff',
Size: 15,
Hidden: false,
HiddenIfNotPrimary: false,
},
Username: {
Color: '#55aaff',
Size: 15,
},
Message: {
Color: '#ffffff',
Size: 15,
},
Format: {
Chat: '{Title} {Username}: {Message}',
Console: '{Title} {Username}: {Message}',
},
}
// Editing group state
const editGroup = ref<Record<string, any>>({ ...JSON.parse(JSON.stringify(defaultGroup)) })
onMounted(async () => {
await store.fetchConfigs()
if (store.configs.length > 0 && store.configs[0]) {
await store.loadConfig(store.configs[0].id)
}
})
// --- Config data helpers ---
function getConfigValue(path: string, defaultValue: any = false): any {
if (!store.currentConfig?.config_data) return defaultValue
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (const part of parts) {
if (current == null || typeof current !== 'object') return defaultValue
current = current[part]
}
return current ?? defaultValue
}
function setConfigValue(path: string, value: any) {
if (!store.currentConfig) return
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!
if (current[part] == null || typeof current[part] !== 'object') {
current[part] = {}
}
current = current[part]
}
current[parts[parts.length - 1]!] = value
store.markDirty()
}
// --- Chat Groups helpers ---
const chatGroups = computed<Record<string, any>[]>(() => {
if (!store.currentConfig?.config_data) return []
// BetterChat stores groups in oxide/data, but config has a ChatGroup array
// The actual config format uses a top-level array for groups in the data file
// We support both: config_data as array or config_data.ChatGroups as array
if (Array.isArray(store.currentConfig.config_data)) {
return store.currentConfig.config_data
}
return getConfigValue('ChatGroups', []) as Record<string, any>[]
})
function addGroup() {
editingGroupIndex.value = null
editGroup.value = JSON.parse(JSON.stringify(defaultGroup))
showGroupModal.value = true
}
function editGroupAt(index: number) {
editingGroupIndex.value = index
editGroup.value = JSON.parse(JSON.stringify(chatGroups.value[index]))
showGroupModal.value = true
}
function saveGroup() {
if (!store.currentConfig) return
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
const groups = [...chatGroups.value]
if (editingGroupIndex.value !== null) {
groups[editingGroupIndex.value] = JSON.parse(JSON.stringify(editGroup.value))
} else {
groups.push(JSON.parse(JSON.stringify(editGroup.value)))
}
if (Array.isArray(store.currentConfig.config_data)) {
store.currentConfig.config_data = groups as any
} else {
store.currentConfig.config_data.ChatGroups = groups
}
store.markDirty()
showGroupModal.value = false
}
function deleteGroup(index: number) {
if (!store.currentConfig) return
if (!confirm('Remove this chat group?')) return
const groups = [...chatGroups.value]
groups.splice(index, 1)
if (Array.isArray(store.currentConfig.config_data)) {
store.currentConfig.config_data = groups as any
} else {
store.currentConfig.config_data.ChatGroups = groups
}
store.markDirty()
}
// --- Action handlers ---
async function handleConfigChange(id: string) {
if (store.isDirty) {
if (!confirm('Unsaved changes will be lost. Continue?')) return
}
await store.loadConfig(id)
}
async function handleCreateConfig() {
if (!newConfigName.value.trim()) return
const config = await store.createConfig(
newConfigName.value.trim(),
newConfigDesc.value.trim() || undefined,
)
if (config) {
showCreateModal.value = false
newConfigName.value = ''
newConfigDesc.value = ''
}
}
async function handleDeleteConfig() {
if (!store.currentConfig) return
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
await store.deleteConfig(store.currentConfig.id)
}
async function handleApply() {
if (!store.currentConfig) return
if (!confirm('Apply this BetterChat config to the server? This will overwrite the current config.')) return
if (store.isDirty) {
await store.saveCurrentConfig()
}
await store.applyToServer(store.currentConfig.id)
}
async function handleImport() {
if (!importConfigName.value.trim()) return
const config = await store.importFromServer(importConfigName.value.trim())
if (config) {
showImportModal.value = false
importConfigName.value = ''
}
}
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">Better Chat</h1>
<div class="flex items-center gap-3">
<button
@click="showCreateModal = true"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Plus class="w-4 h-4" />
New Config
</button>
</div>
</div>
<!-- Config Selector + Action Bar -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div class="flex items-center gap-3 flex-wrap">
<!-- Config Selector -->
<select
v-if="store.configs.length > 0"
:value="store.currentConfig?.id || ''"
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
>
<option v-for="c in store.configs" :key="c.id" :value="c.id">
{{ c.config_name }}
<template v-if="c.is_active"> (Active)</template>
</option>
</select>
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
<!-- Save -->
<button
@click="store.saveCurrentConfig()"
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Save class="w-4 h-4" />
{{ store.isSaving ? 'Saving...' : 'Save' }}
</button>
<!-- Apply to Server -->
<button
@click="handleApply"
:disabled="!store.currentConfig || store.isApplying"
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Play class="w-4 h-4" />
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
</button>
<!-- Import from Server -->
<button
@click="showImportModal = true"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Download class="w-4 h-4" />
Import from Server
</button>
<!-- Delete -->
<button
@click="handleDeleteConfig"
:disabled="!store.currentConfig"
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
>
<Trash2 class="w-4 h-4" />
Delete
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
</div>
<!-- No Config Selected -->
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
<MessageSquare class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No BetterChat Config Selected</h2>
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
<button
@click="showCreateModal = true"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
>
Create First Config
</button>
</div>
<!-- Config Editor -->
<div v-else class="space-y-6">
<!-- Tab Bar -->
<div class="flex border-b border-neutral-800">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key as typeof activeTab"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === tab.key
? 'border-oxide-500 text-oxide-400'
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
>
<component :is="tab.icon" class="w-4 h-4" />
{{ tab.label }}
</button>
</div>
<!-- Chat Groups Tab -->
<div v-if="activeTab === 'groups'" class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Chat Groups</h3>
<button
@click="addGroup"
class="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Plus class="w-4 h-4" />
Add Group
</button>
</div>
<div v-if="chatGroups.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center">
<p class="text-neutral-500">No chat groups configured. Add a group to get started.</p>
</div>
<div v-else class="space-y-3">
<div
v-for="(group, index) in chatGroups"
:key="index"
class="bg-neutral-900 border border-neutral-800 rounded-xl p-4"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-2">
<span class="text-sm font-semibold text-neutral-200">{{ group.GroupName || group.groupName || 'Unnamed' }}</span>
<span class="text-xs px-2 py-0.5 bg-neutral-800 text-neutral-400 rounded">Priority: {{ group.Priority ?? 0 }}</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<!-- Title -->
<div>
<span class="text-neutral-500">Title:</span>
<div class="flex items-center gap-1 mt-0.5">
<span
class="inline-block w-3 h-3 rounded border border-neutral-600"
:style="{ backgroundColor: group.Title?.Color || group.TitleColor || '#55aaff' }"
/>
<span class="text-neutral-300">{{ group.Title?.Text || group.Title || '[Player]' }}</span>
</div>
</div>
<!-- Name Color -->
<div>
<span class="text-neutral-500">Name Color:</span>
<div class="flex items-center gap-1 mt-0.5">
<span
class="inline-block w-3 h-3 rounded border border-neutral-600"
:style="{ backgroundColor: group.Username?.Color || group.NameColor || '#55aaff' }"
/>
<span class="text-neutral-300">{{ group.Username?.Color || group.NameColor || '#55aaff' }}</span>
</div>
</div>
<!-- Message Color -->
<div>
<span class="text-neutral-500">Message Color:</span>
<div class="flex items-center gap-1 mt-0.5">
<span
class="inline-block w-3 h-3 rounded border border-neutral-600"
:style="{ backgroundColor: group.Message?.Color || group.MessageColor || '#ffffff' }"
/>
<span class="text-neutral-300">{{ group.Message?.Color || group.MessageColor || '#ffffff' }}</span>
</div>
</div>
<!-- Format -->
<div>
<span class="text-neutral-500">Format:</span>
<p class="text-neutral-300 mt-0.5 truncate">{{ group.Format?.Chat || group.Format || '{Title} {Username}: {Message}' }}</p>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
@click="editGroupAt(index)"
class="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded"
>
<Edit class="w-4 h-4" />
</button>
<button
@click="deleteGroup(index)"
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Settings Tab -->
<div v-else-if="activeTab === 'settings'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">General Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Word Filter Enabled -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Word Filter</label>
<p class="text-xs text-neutral-500">Enable profanity/word filtering in chat</p>
</div>
<button
@click="setConfigValue('Word Filter.Enabled', !getConfigValue('Word Filter.Enabled', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Word Filter.Enabled', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Word Filter.Enabled', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- Anti Flood Enabled -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Anti Flood</label>
<p class="text-xs text-neutral-500">Prevent message spamming</p>
</div>
<button
@click="setConfigValue('Anti Flood.Enabled', !getConfigValue('Anti Flood.Enabled', true))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Anti Flood.Enabled', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Anti Flood.Enabled', true) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- Reverse Title Order -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Reverse Title Order</label>
<p class="text-xs text-neutral-500">Reverse the display order of chat titles</p>
</div>
<button
@click="setConfigValue('General.Reverse Title Order', !getConfigValue('General.Reverse Title Order', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('General.Reverse Title Order', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('General.Reverse Title Order', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- Use Custom Replacement -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Use Custom Replacement</label>
<p class="text-xs text-neutral-500">Use a custom word instead of * for filtered words</p>
</div>
<button
@click="setConfigValue('Word Filter.Use Custom Replacement', !getConfigValue('Word Filter.Use Custom Replacement', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Word Filter.Use Custom Replacement', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Word Filter.Use Custom Replacement', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- Enable Player Tagging -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Enable Player Tagging</label>
<p class="text-xs text-neutral-500">Allow @mentions to highlight players in chat</p>
</div>
<button
@click="setConfigValue('General.Enable Player Tagging', !getConfigValue('General.Enable Player Tagging', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('General.Enable Player Tagging', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('General.Enable Player Tagging', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
<!-- Number / Text Inputs -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label class="block text-sm text-neutral-200 mb-1">Anti Flood Seconds</label>
<p class="text-xs text-neutral-500 mb-2">Minimum seconds between messages</p>
<input
type="number"
step="0.1"
:value="getConfigValue('Anti Flood.Seconds', 1.5)"
@input="setConfigValue('Anti Flood.Seconds', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<div>
<label class="block text-sm text-neutral-200 mb-1">Minimal Tag Characters</label>
<p class="text-xs text-neutral-500 mb-2">Minimum characters for player tag matching</p>
<input
type="number"
:value="getConfigValue('General.Minimal Characters', 2)"
@input="setConfigValue('General.Minimal Characters', Number(($event.target as HTMLInputElement).value))"
min="1"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<div>
<label class="block text-sm text-neutral-200 mb-1">Filter Replacement</label>
<p class="text-xs text-neutral-500 mb-2">Character used to replace filtered words</p>
<input
type="text"
:value="getConfigValue('Word Filter.Replacement', '*')"
@input="setConfigValue('Word Filter.Replacement', ($event.target as HTMLInputElement).value)"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<div>
<label class="block text-sm text-neutral-200 mb-1">Custom Replacement Word</label>
<p class="text-xs text-neutral-500 mb-2">Custom word to replace filtered content</p>
<input
type="text"
:value="getConfigValue('Word Filter.Custom Replacement', 'Unicorn')"
@input="setConfigValue('Word Filter.Custom Replacement', ($event.target as HTMLInputElement).value)"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
</div>
</div>
</div>
<!-- Create Config Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New BetterChat Config</h2>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="newConfigName"
placeholder="e.g. Default Chat Settings"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleCreateConfig"
/>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
<textarea
v-model="newConfigDesc"
rows="2"
placeholder="What is this config for?"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
/>
</div>
<div class="flex justify-end gap-2">
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="handleCreateConfig"
:disabled="!newConfigName.trim()"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
Create
</button>
</div>
</div>
</div>
</div>
<!-- Import from Server Modal -->
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
<p class="text-sm text-neutral-400 mb-4">
Import the current BetterChat config from your live server. This will create a new config profile.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="importConfigName"
placeholder="e.g. Imported Server Config"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleImport"
/>
</div>
<div class="flex justify-end gap-2">
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="handleImport"
:disabled="!importConfigName.trim()"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
Import
</button>
</div>
</div>
</div>
</div>
<!-- Group Edit Modal -->
<div v-if="showGroupModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showGroupModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-neutral-100">
{{ editingGroupIndex !== null ? 'Edit Group' : 'Add Group' }}
</h2>
<button @click="showGroupModal = false" class="text-neutral-400 hover:text-neutral-200">
<X class="w-5 h-5" />
</button>
</div>
<div class="space-y-4">
<!-- Group Name -->
<div>
<label class="block text-sm text-neutral-400 mb-1">Group Name</label>
<input
v-model="editGroup.GroupName"
placeholder="e.g. default, admin, vip"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<!-- Priority -->
<div>
<label class="block text-sm text-neutral-400 mb-1">Priority</label>
<p class="text-xs text-neutral-500 mb-1">Higher priority groups display first (0 = highest)</p>
<input
v-model.number="editGroup.Priority"
type="number"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<!-- Title -->
<div>
<label class="block text-sm text-neutral-400 mb-1">Title Text</label>
<input
v-model="editGroup.Title.Text"
placeholder="e.g. [Admin]"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<!-- Colors Row -->
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Title Color</label>
<div class="flex items-center gap-2">
<input
type="color"
v-model="editGroup.Title.Color"
class="w-8 h-8 rounded border border-neutral-700 bg-transparent cursor-pointer"
/>
<input
v-model="editGroup.Title.Color"
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-xs"
/>
</div>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-1">Name Color</label>
<div class="flex items-center gap-2">
<input
type="color"
v-model="editGroup.Username.Color"
class="w-8 h-8 rounded border border-neutral-700 bg-transparent cursor-pointer"
/>
<input
v-model="editGroup.Username.Color"
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-xs"
/>
</div>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-1">Message Color</label>
<div class="flex items-center gap-2">
<input
type="color"
v-model="editGroup.Message.Color"
class="w-8 h-8 rounded border border-neutral-700 bg-transparent cursor-pointer"
/>
<input
v-model="editGroup.Message.Color"
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-xs"
/>
</div>
</div>
</div>
<!-- Font Sizes -->
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Title Size</label>
<input
v-model.number="editGroup.Title.Size"
type="number"
min="8"
max="30"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-1">Name Size</label>
<input
v-model.number="editGroup.Username.Size"
type="number"
min="8"
max="30"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-1">Message Size</label>
<input
v-model.number="editGroup.Message.Size"
type="number"
min="8"
max="30"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
</div>
<!-- Format Strings -->
<div>
<label class="block text-sm text-neutral-400 mb-1">Chat Format</label>
<p class="text-xs text-neutral-500 mb-1">Variables: {Title} {Username} {Message}</p>
<input
v-model="editGroup.Format.Chat"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-1">Console Format</label>
<input
v-model="editGroup.Format.Console"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<!-- Hidden toggles -->
<div class="flex items-center gap-6">
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input type="checkbox" v-model="editGroup.Title.Hidden" class="rounded bg-neutral-800 border-neutral-600" />
Hidden
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input type="checkbox" v-model="editGroup.Title.HiddenIfNotPrimary" class="rounded bg-neutral-800 border-neutral-600" />
Hidden if not primary
</label>
</div>
<div class="flex justify-end gap-2 pt-2">
<button @click="showGroupModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="saveGroup"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 text-sm"
>
{{ editingGroupIndex !== null ? 'Update' : 'Add' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,371 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useFurnaceSplitterStore } from '@/stores/furnacesplitter'
import {
Save,
Play,
Download,
Plus,
Trash2,
Flame,
Settings as SettingsIcon,
} from 'lucide-vue-next'
const store = useFurnaceSplitterStore()
const showCreateModal = ref(false)
const showImportModal = ref(false)
const newConfigName = ref('')
const newConfigDesc = ref('')
const importConfigName = ref('')
onMounted(async () => {
await store.fetchConfigs()
if (store.configs.length > 0 && store.configs[0]) {
await store.loadConfig(store.configs[0].id)
}
})
// --- Config data helpers ---
function getConfigValue(path: string, defaultValue: any = false): any {
if (!store.currentConfig?.config_data) return defaultValue
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (const part of parts) {
if (current == null || typeof current !== 'object') return defaultValue
current = current[part]
}
return current ?? defaultValue
}
function setConfigValue(path: string, value: any) {
if (!store.currentConfig) return
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!
if (current[part] == null || typeof current[part] !== 'object') {
current[part] = {}
}
current = current[part]
}
current[parts[parts.length - 1]!] = value
store.markDirty()
}
// Furnace types with display names
const furnaceTypes = [
{ key: 'furnace', label: 'Small Furnace', description: 'Standard furnace for smelting ores' },
{ key: 'furnace.large', label: 'Large Furnace', description: 'Large furnace with more slots' },
{ key: 'campfire', label: 'Campfire', description: 'Basic campfire for cooking' },
{ key: 'refinery_small_deployed', label: 'Small Oil Refinery', description: 'Refines crude oil into low grade fuel' },
{ key: 'skull_fire_pit', label: 'Skull Fire Pit', description: 'Decorative fire pit for cooking' },
{ key: 'hobobarrel_static', label: 'Hobo Barrel', description: 'Barrel fire for cooking' },
{ key: 'electricfurnace.deployed', label: 'Electric Furnace', description: 'Electricity-powered furnace' },
]
// --- Action handlers ---
async function handleConfigChange(id: string) {
if (store.isDirty) {
if (!confirm('Unsaved changes will be lost. Continue?')) return
}
await store.loadConfig(id)
}
async function handleCreateConfig() {
if (!newConfigName.value.trim()) return
const config = await store.createConfig(
newConfigName.value.trim(),
newConfigDesc.value.trim() || undefined,
)
if (config) {
showCreateModal.value = false
newConfigName.value = ''
newConfigDesc.value = ''
}
}
async function handleDeleteConfig() {
if (!store.currentConfig) return
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
await store.deleteConfig(store.currentConfig.id)
}
async function handleApply() {
if (!store.currentConfig) return
if (!confirm('Apply this FurnaceSplitter config to the server? This will overwrite the current config.')) return
if (store.isDirty) {
await store.saveCurrentConfig()
}
await store.applyToServer(store.currentConfig.id)
}
async function handleImport() {
if (!importConfigName.value.trim()) return
const config = await store.importFromServer(importConfigName.value.trim())
if (config) {
showImportModal.value = false
importConfigName.value = ''
}
}
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">Furnace Splitter Config</h1>
<div class="flex items-center gap-3">
<button
@click="showCreateModal = true"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Plus class="w-4 h-4" />
New Config
</button>
</div>
</div>
<!-- Config Selector + Action Bar -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div class="flex items-center gap-3 flex-wrap">
<!-- Config Selector -->
<select
v-if="store.configs.length > 0"
:value="store.currentConfig?.id || ''"
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
>
<option v-for="c in store.configs" :key="c.id" :value="c.id">
{{ c.config_name }}
<template v-if="c.is_active"> (Active)</template>
</option>
</select>
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
<!-- Save -->
<button
@click="store.saveCurrentConfig()"
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Save class="w-4 h-4" />
{{ store.isSaving ? 'Saving...' : 'Save' }}
</button>
<!-- Apply to Server -->
<button
@click="handleApply"
:disabled="!store.currentConfig || store.isApplying"
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Play class="w-4 h-4" />
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
</button>
<!-- Import from Server -->
<button
@click="showImportModal = true"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Download class="w-4 h-4" />
Import from Server
</button>
<!-- Delete -->
<button
@click="handleDeleteConfig"
:disabled="!store.currentConfig"
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
>
<Trash2 class="w-4 h-4" />
Delete
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
</div>
<!-- No Config Selected -->
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
<Flame class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No FurnaceSplitter Config Selected</h2>
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
<button
@click="showCreateModal = true"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
>
Create First Config
</button>
</div>
<!-- Config Editor -->
<div v-else class="space-y-6">
<!-- Furnace Splitter Settings -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
<div class="flex items-center gap-2">
<SettingsIcon class="w-5 h-5 text-neutral-400" />
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Splitter Settings</h3>
</div>
<!-- Global enabled -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Enabled</label>
<p class="text-xs text-neutral-500">Globally enable or disable furnace splitting</p>
</div>
<button
@click="setConfigValue('Enabled', !getConfigValue('Enabled', true))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Enabled', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Enabled', true) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
<!-- Per-Furnace Type Settings -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
<div class="flex items-center gap-2">
<Flame class="w-5 h-5 text-neutral-400" />
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Furnace Type Settings</h3>
</div>
<div class="space-y-4">
<div
v-for="furnace in furnaceTypes"
:key="furnace.key"
class="bg-neutral-800/50 border border-neutral-700/50 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-3">
<div>
<h4 class="text-sm font-medium text-neutral-200">{{ furnace.label }}</h4>
<p class="text-xs text-neutral-500">{{ furnace.description }}</p>
</div>
<button
@click="setConfigValue(`Furnaces.${furnace.key}.Enabled`, !getConfigValue(`Furnaces.${furnace.key}.Enabled`, true))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue(`Furnaces.${furnace.key}.Enabled`, true) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue(`Furnaces.${furnace.key}.Enabled`, true) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-xs text-neutral-500 mb-1">Default Split Stacks</label>
<input
type="number"
:value="getConfigValue(`Furnaces.${furnace.key}.DefaultSplitCount`, 0)"
@input="setConfigValue(`Furnaces.${furnace.key}.DefaultSplitCount`, Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-neutral-200 text-sm"
placeholder="0 = fill all slots"
/>
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">Fuel Multiplier</label>
<input
type="number"
step="0.1"
:value="getConfigValue(`Furnaces.${furnace.key}.FuelMultiplier`, 1.0)"
@input="setConfigValue(`Furnaces.${furnace.key}.FuelMultiplier`, Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-neutral-200 text-sm"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Permission Groups -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Permission</h3>
<p class="text-xs text-neutral-500">
The permission <code class="text-neutral-300 bg-neutral-800 px-1 rounded">furnacesplitter.use</code> controls which players can use the furnace splitting feature. Assign this permission via your Oxide permission system.
</p>
</div>
</div>
<!-- Create Config Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New FurnaceSplitter Config</h2>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="newConfigName"
placeholder="e.g. Default Furnace Settings"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleCreateConfig"
/>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
<textarea
v-model="newConfigDesc"
rows="2"
placeholder="What is this config for?"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
/>
</div>
<div class="flex justify-end gap-2">
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="handleCreateConfig"
:disabled="!newConfigName.trim()"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
Create
</button>
</div>
</div>
</div>
</div>
<!-- Import from Server Modal -->
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
<p class="text-sm text-neutral-400 mb-4">
Import the current FurnaceSplitter config from your live server. This will create a new config profile.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="importConfigName"
placeholder="e.g. Imported Server Config"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleImport"
/>
</div>
<div class="flex justify-end gap-2">
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="handleImport"
:disabled="!importConfigName.trim()"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
Import
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,534 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useGatherStore } from '@/stores/gather'
import {
Save,
Play,
Download,
Plus,
Trash2,
Pickaxe,
Settings as SettingsIcon,
} from 'lucide-vue-next'
const store = useGatherStore()
const activeTab = ref<'resources' | 'advanced'>('resources')
const showCreateModal = ref(false)
const showImportModal = ref(false)
const newConfigName = ref('')
const newConfigDesc = ref('')
const importConfigName = ref('')
const tabs = [
{ key: 'resources', label: 'Resource Rates', icon: Pickaxe },
{ key: 'advanced', label: 'Advanced', icon: SettingsIcon },
]
// Resource definitions for the main gather tab
const gatherResources = [
{ key: 'Wood', label: 'Wood' },
{ key: 'Stones', label: 'Stones' },
{ key: 'Metal Ore', label: 'Metal Ore' },
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
{ key: 'HQM Ore', label: 'HQM Ore' },
{ key: 'Cloth', label: 'Cloth' },
{ key: 'Leather', label: 'Leather' },
{ key: 'Animal Fat', label: 'Animal Fat' },
{ key: 'Bone Fragments', label: 'Bone Fragments' },
]
// Advanced resource categories
const pickupResources = [
{ key: 'Wood', label: 'Wood' },
{ key: 'Stones', label: 'Stones' },
{ key: 'Metal Ore', label: 'Metal Ore' },
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
]
const quarryResources = [
{ key: 'HQM Ore', label: 'HQM Ore' },
{ key: 'Metal Ore', label: 'Metal Ore' },
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
{ key: 'Stones', label: 'Stones' },
]
const excavatorResources = [
{ key: 'HQM Ore', label: 'HQM Ore' },
{ key: 'Metal Ore', label: 'Metal Ore' },
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
{ key: 'Stones', label: 'Stones' },
]
const surveyResources = [
{ key: 'Metal Ore', label: 'Metal Ore' },
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
{ key: 'Stones', label: 'Stones' },
{ key: 'HQM Ore', label: 'HQM Ore' },
]
const presets = [
{ label: '1x', value: 1 },
{ label: '2x', value: 2 },
{ label: '3x', value: 3 },
{ label: '5x', value: 5 },
{ label: '10x', value: 10 },
]
onMounted(async () => {
await store.fetchConfigs()
if (store.configs.length > 0 && store.configs[0]) {
await store.loadConfig(store.configs[0].id)
}
})
// --- Config data helpers ---
function getConfigValue(path: string, defaultValue: any = 1): any {
if (!store.currentConfig?.config_data) return defaultValue
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (const part of parts) {
if (current == null || typeof current !== 'object') return defaultValue
current = current[part]
}
return current ?? defaultValue
}
function setConfigValue(path: string, value: any) {
if (!store.currentConfig) return
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!
if (current[part] == null || typeof current[part] !== 'object') {
current[part] = {}
}
current = current[part]
}
current[parts[parts.length - 1]!] = value
store.markDirty()
}
// --- Preset handler ---
function applyPreset(multiplier: number) {
for (const resource of gatherResources) {
setConfigValue(`GatherResourceModifiers.${resource.key}`, multiplier)
}
}
// --- Action handlers ---
async function handleConfigChange(id: string) {
if (store.isDirty) {
if (!confirm('Unsaved changes will be lost. Continue?')) return
}
await store.loadConfig(id)
}
async function handleCreateConfig() {
if (!newConfigName.value.trim()) return
const config = await store.createConfig(
newConfigName.value.trim(),
newConfigDesc.value.trim() || undefined,
)
if (config) {
showCreateModal.value = false
newConfigName.value = ''
newConfigDesc.value = ''
}
}
async function handleDeleteConfig() {
if (!store.currentConfig) return
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
await store.deleteConfig(store.currentConfig.id)
}
async function handleApply() {
if (!store.currentConfig) return
if (!confirm('Apply this gather config to the server? This will overwrite the current GatherManager config.')) return
if (store.isDirty) {
await store.saveCurrentConfig()
}
await store.applyToServer(store.currentConfig.id)
}
async function handleImport() {
if (!importConfigName.value.trim()) return
const config = await store.importFromServer(importConfigName.value.trim())
if (config) {
showImportModal.value = false
importConfigName.value = ''
}
}
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">Gather Rates</h1>
<div class="flex items-center gap-3">
<button
@click="showCreateModal = true"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Plus class="w-4 h-4" />
New Config
</button>
</div>
</div>
<!-- Config Selector + Action Bar -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div class="flex items-center gap-3 flex-wrap">
<!-- Config Selector -->
<select
v-if="store.configs.length > 0"
:value="store.currentConfig?.id || ''"
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
>
<option v-for="c in store.configs" :key="c.id" :value="c.id">
{{ c.config_name }}
<template v-if="c.is_active"> (Active)</template>
</option>
</select>
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
<!-- Save -->
<button
@click="store.saveCurrentConfig()"
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Save class="w-4 h-4" />
{{ store.isSaving ? 'Saving...' : 'Save' }}
</button>
<!-- Apply to Server -->
<button
@click="handleApply"
:disabled="!store.currentConfig || store.isApplying"
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Play class="w-4 h-4" />
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
</button>
<!-- Import from Server -->
<button
@click="showImportModal = true"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Download class="w-4 h-4" />
Import from Server
</button>
<!-- Delete -->
<button
@click="handleDeleteConfig"
:disabled="!store.currentConfig"
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
>
<Trash2 class="w-4 h-4" />
Delete
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
</div>
<!-- No Config Selected -->
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
<Pickaxe class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Gather Config Selected</h2>
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
<button
@click="showCreateModal = true"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
>
Create First Config
</button>
</div>
<!-- Config Editor -->
<div v-else class="space-y-6">
<!-- Tab Bar -->
<div class="flex border-b border-neutral-800">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key as typeof activeTab"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === tab.key
? 'border-oxide-500 text-oxide-400'
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
>
<component :is="tab.icon" class="w-4 h-4" />
{{ tab.label }}
</button>
</div>
<!-- Resource Rates Tab -->
<div v-if="activeTab === 'resources'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Gather Resource Modifiers</h3>
<div class="flex items-center gap-2">
<span class="text-xs text-neutral-500 mr-2">Presets:</span>
<button
v-for="preset in presets"
:key="preset.value"
@click="applyPreset(preset.value)"
class="px-3 py-1 text-xs bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 hover:text-white transition-colors"
>
{{ preset.label }}
</button>
</div>
</div>
<div class="space-y-4">
<div
v-for="resource in gatherResources"
:key="resource.key"
class="flex items-center gap-4"
>
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
<input
type="range"
:value="getConfigValue(`GatherResourceModifiers.${resource.key}`, 1)"
@input="setConfigValue(`GatherResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
min="0.1"
max="100"
step="0.1"
class="flex-1 accent-oxide-500"
/>
<input
type="number"
:value="getConfigValue(`GatherResourceModifiers.${resource.key}`, 1)"
@input="setConfigValue(`GatherResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
min="0.1"
max="1000"
step="0.1"
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
/>
<span class="text-xs text-neutral-500 w-4">x</span>
</div>
</div>
</div>
<!-- Advanced Tab -->
<div v-else-if="activeTab === 'advanced'" class="space-y-6">
<!-- Pickup Resource Modifiers -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Pickup Resource Modifiers</h3>
<p class="text-xs text-neutral-500">Modify rates for resources picked up from the ground (small rocks, wood piles).</p>
<div class="space-y-4">
<div
v-for="resource in pickupResources"
:key="resource.key"
class="flex items-center gap-4"
>
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
<input
type="range"
:value="getConfigValue(`PickupResourceModifiers.${resource.key}`, 1)"
@input="setConfigValue(`PickupResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
min="0.1"
max="100"
step="0.1"
class="flex-1 accent-oxide-500"
/>
<input
type="number"
:value="getConfigValue(`PickupResourceModifiers.${resource.key}`, 1)"
@input="setConfigValue(`PickupResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
min="0.1"
max="1000"
step="0.1"
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
/>
<span class="text-xs text-neutral-500 w-4">x</span>
</div>
</div>
</div>
<!-- Quarry Resource Modifiers -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Quarry Resource Modifiers</h3>
<p class="text-xs text-neutral-500">Scale resource output from Mining Quarries.</p>
<div class="space-y-4">
<div
v-for="resource in quarryResources"
:key="resource.key"
class="flex items-center gap-4"
>
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
<input
type="range"
:value="getConfigValue(`QuarryResourceModifiers.${resource.key}`, 1)"
@input="setConfigValue(`QuarryResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
min="0.1"
max="100"
step="0.1"
class="flex-1 accent-oxide-500"
/>
<input
type="number"
:value="getConfigValue(`QuarryResourceModifiers.${resource.key}`, 1)"
@input="setConfigValue(`QuarryResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
min="0.1"
max="1000"
step="0.1"
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
/>
<span class="text-xs text-neutral-500 w-4">x</span>
</div>
</div>
</div>
<!-- Excavator Resource Modifiers -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Excavator Resource Modifiers</h3>
<p class="text-xs text-neutral-500">Scale resource output from the Giant Excavator.</p>
<div class="space-y-4">
<div
v-for="resource in excavatorResources"
:key="resource.key"
class="flex items-center gap-4"
>
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
<input
type="range"
:value="getConfigValue(`ExcavatorResourceModifiers.${resource.key}`, 1)"
@input="setConfigValue(`ExcavatorResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
min="0.1"
max="100"
step="0.1"
class="flex-1 accent-oxide-500"
/>
<input
type="number"
:value="getConfigValue(`ExcavatorResourceModifiers.${resource.key}`, 1)"
@input="setConfigValue(`ExcavatorResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
min="0.1"
max="1000"
step="0.1"
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
/>
<span class="text-xs text-neutral-500 w-4">x</span>
</div>
</div>
</div>
<!-- Survey Resource Modifiers -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Survey Charge Resource Modifiers</h3>
<p class="text-xs text-neutral-500">Modify resource amounts from Survey Charge grenades.</p>
<div class="space-y-4">
<div
v-for="resource in surveyResources"
:key="resource.key"
class="flex items-center gap-4"
>
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
<input
type="range"
:value="getConfigValue(`SurveyResourceModifiers.${resource.key}`, 1)"
@input="setConfigValue(`SurveyResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
min="0.1"
max="100"
step="0.1"
class="flex-1 accent-oxide-500"
/>
<input
type="number"
:value="getConfigValue(`SurveyResourceModifiers.${resource.key}`, 1)"
@input="setConfigValue(`SurveyResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
min="0.1"
max="1000"
step="0.1"
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
/>
<span class="text-xs text-neutral-500 w-4">x</span>
</div>
</div>
</div>
</div>
</div>
<!-- Create Config Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New Gather Config</h2>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="newConfigName"
placeholder="e.g. 3x Gather Rates"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleCreateConfig"
/>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
<textarea
v-model="newConfigDesc"
rows="2"
placeholder="What is this config for?"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
/>
</div>
<div class="flex justify-end gap-2">
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="handleCreateConfig"
:disabled="!newConfigName.trim()"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
Create
</button>
</div>
</div>
</div>
</div>
<!-- Import from Server Modal -->
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
<p class="text-sm text-neutral-400 mb-4">
Import the current GatherManager config from your live server. This will create a new config profile.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="importConfigName"
placeholder="e.g. Imported Server Config"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleImport"
/>
</div>
<div class="flex justify-end gap-2">
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="handleImport"
:disabled="!importConfigName.trim()"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
Import
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,778 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useKitsStore } from '@/stores/kits'
import {
Save,
Play,
Download,
Plus,
Trash2,
Package,
Settings as SettingsIcon,
Edit,
X,
} from 'lucide-vue-next'
const store = useKitsStore()
const activeTab = ref<'kits' | 'editor' | 'settings'>('kits')
const showCreateModal = ref(false)
const showImportModal = ref(false)
const newConfigName = ref('')
const newConfigDesc = ref('')
const importConfigName = ref('')
const editingKitIndex = ref<number | null>(null)
const tabs = [
{ key: 'kits', label: 'Kits List', icon: Package },
{ key: 'editor', label: 'Kit Editor', icon: Edit },
{ key: 'settings', label: 'Settings', icon: SettingsIcon },
]
onMounted(async () => {
await store.fetchConfigs()
if (store.configs.length > 0 && store.configs[0]) {
await store.loadConfig(store.configs[0].id)
}
})
// --- Config data helpers ---
function getConfigValue(path: string, defaultValue: any = false): any {
if (!store.currentConfig?.config_data) return defaultValue
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (const part of parts) {
if (current == null || typeof current !== 'object') return defaultValue
current = current[part]
}
return current ?? defaultValue
}
function setConfigValue(path: string, value: any) {
if (!store.currentConfig) return
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!
if (current[part] == null || typeof current[part] !== 'object') {
current[part] = {}
}
current = current[part]
}
current[parts[parts.length - 1]!] = value
store.markDirty()
}
// --- Kit List helpers ---
const kitsList = computed(() => {
return getConfigValue('Kits', []) as any[]
})
function addKit() {
const kits = getConfigValue('Kits', []) as any[]
kits.push({
Name: `New Kit ${kits.length + 1}`,
Description: '',
Permission: '',
Cooldown: 600,
MaxUses: 0,
IsHidden: false,
Items: [],
})
setConfigValue('Kits', kits)
editingKitIndex.value = kits.length - 1
activeTab.value = 'editor'
}
function editKit(index: number) {
editingKitIndex.value = index
activeTab.value = 'editor'
}
function deleteKit(index: number) {
const kits = getConfigValue('Kits', []) as any[]
if (!confirm(`Delete kit "${kits[index]?.Name}"? This cannot be undone.`)) return
kits.splice(index, 1)
setConfigValue('Kits', kits)
if (editingKitIndex.value === index) {
editingKitIndex.value = null
activeTab.value = 'kits'
} else if (editingKitIndex.value !== null && editingKitIndex.value > index) {
editingKitIndex.value--
}
}
// --- Kit Editor helpers ---
const currentKit = computed(() => {
if (editingKitIndex.value === null) return null
const kits = getConfigValue('Kits', []) as any[]
return kits[editingKitIndex.value] || null
})
function setKitField(field: string, value: any) {
if (editingKitIndex.value === null) return
const kits = getConfigValue('Kits', []) as any[]
if (kits[editingKitIndex.value]) {
kits[editingKitIndex.value][field] = value
setConfigValue('Kits', kits)
}
}
function addKitItem() {
if (editingKitIndex.value === null) return
const kits = getConfigValue('Kits', []) as any[]
const kit = kits[editingKitIndex.value]
if (!kit) return
if (!kit.Items) kit.Items = []
kit.Items.push({
ShortName: '',
Amount: 1,
SkinId: 0,
Container: 'main',
Position: -1,
})
setConfigValue('Kits', kits)
}
function removeKitItem(itemIndex: number) {
if (editingKitIndex.value === null) return
const kits = getConfigValue('Kits', []) as any[]
const kit = kits[editingKitIndex.value]
if (!kit?.Items) return
kit.Items.splice(itemIndex, 1)
setConfigValue('Kits', kits)
}
function setKitItemField(itemIndex: number, field: string, value: any) {
if (editingKitIndex.value === null) return
const kits = getConfigValue('Kits', []) as any[]
const kit = kits[editingKitIndex.value]
if (!kit?.Items?.[itemIndex]) return
kit.Items[itemIndex][field] = value
setConfigValue('Kits', kits)
}
// --- Action handlers ---
async function handleConfigChange(id: string) {
if (store.isDirty) {
if (!confirm('Unsaved changes will be lost. Continue?')) return
}
await store.loadConfig(id)
editingKitIndex.value = null
activeTab.value = 'kits'
}
async function handleCreateConfig() {
if (!newConfigName.value.trim()) return
const config = await store.createConfig(
newConfigName.value.trim(),
newConfigDesc.value.trim() || undefined,
)
if (config) {
showCreateModal.value = false
newConfigName.value = ''
newConfigDesc.value = ''
}
}
async function handleDeleteConfig() {
if (!store.currentConfig) return
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
await store.deleteConfig(store.currentConfig.id)
}
async function handleApply() {
if (!store.currentConfig) return
if (!confirm('Apply this kits config to the server? This will overwrite the current Kits config.')) return
if (store.isDirty) {
await store.saveCurrentConfig()
}
await store.applyToServer(store.currentConfig.id)
}
async function handleImport() {
if (!importConfigName.value.trim()) return
const config = await store.importFromServer(importConfigName.value.trim())
if (config) {
showImportModal.value = false
importConfigName.value = ''
}
}
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">Kits Config</h1>
<div class="flex items-center gap-3">
<button
@click="showCreateModal = true"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Plus class="w-4 h-4" />
New Config
</button>
</div>
</div>
<!-- Config Selector + Action Bar -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div class="flex items-center gap-3 flex-wrap">
<!-- Config Selector -->
<select
v-if="store.configs.length > 0"
:value="store.currentConfig?.id || ''"
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
>
<option v-for="c in store.configs" :key="c.id" :value="c.id">
{{ c.config_name }}
<template v-if="c.is_active"> (Active)</template>
</option>
</select>
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
<!-- Save -->
<button
@click="store.saveCurrentConfig()"
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Save class="w-4 h-4" />
{{ store.isSaving ? 'Saving...' : 'Save' }}
</button>
<!-- Apply to Server -->
<button
@click="handleApply"
:disabled="!store.currentConfig || store.isApplying"
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Play class="w-4 h-4" />
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
</button>
<!-- Import from Server -->
<button
@click="showImportModal = true"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Download class="w-4 h-4" />
Import from Server
</button>
<!-- Delete -->
<button
@click="handleDeleteConfig"
:disabled="!store.currentConfig"
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
>
<Trash2 class="w-4 h-4" />
Delete
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
</div>
<!-- No Config Selected -->
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
<Package class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Kits Config Selected</h2>
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
<button
@click="showCreateModal = true"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
>
Create First Config
</button>
</div>
<!-- Config Editor -->
<div v-else class="space-y-6">
<!-- Tab Bar -->
<div class="flex border-b border-neutral-800">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key as typeof activeTab"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === tab.key
? 'border-oxide-500 text-oxide-400'
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
>
<component :is="tab.icon" class="w-4 h-4" />
{{ tab.label }}
</button>
</div>
<!-- Kits List Tab -->
<div v-if="activeTab === 'kits'" class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Defined Kits</h3>
<button
@click="addKit"
class="flex items-center gap-2 px-3 py-1.5 bg-oxide-500/10 text-oxide-400 rounded-lg hover:bg-oxide-500/20 text-sm"
>
<Plus class="w-4 h-4" />
Add Kit
</button>
</div>
<div v-if="kitsList.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center">
<p class="text-neutral-500">No kits defined yet. Add your first kit or import from server.</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="(kit, index) in kitsList"
:key="index"
class="bg-neutral-900 border border-neutral-800 rounded-xl p-4 space-y-3"
>
<div class="flex items-start justify-between">
<div>
<h4 class="text-sm font-semibold text-neutral-200">{{ kit.Name || 'Unnamed Kit' }}</h4>
<p v-if="kit.Description" class="text-xs text-neutral-500 mt-1">{{ kit.Description }}</p>
</div>
<div class="flex items-center gap-1">
<button
@click="editKit(index)"
class="p-1.5 text-neutral-400 hover:text-oxide-400 transition-colors"
title="Edit kit"
>
<Edit class="w-4 h-4" />
</button>
<button
@click="deleteKit(index)"
class="p-1.5 text-neutral-400 hover:text-red-400 transition-colors"
title="Delete kit"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div>
<span class="text-neutral-500">Permission:</span>
<span class="text-neutral-300 ml-1">{{ kit.Permission || 'None' }}</span>
</div>
<div>
<span class="text-neutral-500">Cooldown:</span>
<span class="text-neutral-300 ml-1">{{ kit.Cooldown || 0 }}s</span>
</div>
<div>
<span class="text-neutral-500">Max Uses:</span>
<span class="text-neutral-300 ml-1">{{ kit.MaxUses || 0 }} {{ (kit.MaxUses || 0) === 0 ? '(unlimited)' : '' }}</span>
</div>
<div>
<span class="text-neutral-500">Items:</span>
<span class="text-neutral-300 ml-1">{{ (kit.Items || []).length }}</span>
</div>
</div>
<div v-if="kit.IsHidden" class="text-xs text-yellow-500/80">
Hidden (requires permission)
</div>
</div>
</div>
</div>
<!-- Kit Editor Tab -->
<div v-else-if="activeTab === 'editor'" class="space-y-6">
<div v-if="!currentKit" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center">
<p class="text-neutral-500">Select a kit from the Kits List tab to edit it.</p>
</div>
<template v-else>
<!-- Kit Metadata -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Kit Details</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm text-neutral-200 mb-1">Kit Name</label>
<input
type="text"
:value="currentKit.Name"
@input="setKitField('Name', ($event.target as HTMLInputElement).value)"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
placeholder="e.g. Starter Kit"
/>
</div>
<div>
<label class="block text-sm text-neutral-200 mb-1">Permission</label>
<input
type="text"
:value="currentKit.Permission"
@input="setKitField('Permission', ($event.target as HTMLInputElement).value)"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
placeholder="e.g. kits.vip"
/>
</div>
</div>
<div>
<label class="block text-sm text-neutral-200 mb-1">Description</label>
<textarea
:value="currentKit.Description"
@input="setKitField('Description', ($event.target as HTMLTextAreaElement).value)"
rows="2"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
placeholder="Kit description..."
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm text-neutral-200 mb-1">Cooldown (seconds)</label>
<input
type="number"
:value="currentKit.Cooldown || 0"
@input="setKitField('Cooldown', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<div>
<label class="block text-sm text-neutral-200 mb-1">Max Uses (0 = unlimited)</label>
<input
type="number"
:value="currentKit.MaxUses || 0"
@input="setKitField('MaxUses', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<div class="flex items-center justify-between pt-6">
<label class="text-sm text-neutral-200">Hidden (no perm = hidden)</label>
<button
@click="setKitField('IsHidden', !currentKit.IsHidden)"
class="relative w-11 h-6 rounded-full transition-colors"
:class="currentKit.IsHidden ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="currentKit.IsHidden ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
</div>
<!-- Kit Items -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Kit Items</h3>
<button
@click="addKitItem"
class="flex items-center gap-2 px-3 py-1.5 bg-oxide-500/10 text-oxide-400 rounded-lg hover:bg-oxide-500/20 text-sm"
>
<Plus class="w-4 h-4" />
Add Item
</button>
</div>
<div v-if="!currentKit.Items || currentKit.Items.length === 0" class="text-center py-4">
<p class="text-neutral-500 text-sm">No items in this kit. Click "Add Item" to get started.</p>
</div>
<div v-else class="space-y-3">
<div
v-for="(item, itemIdx) in (currentKit.Items as any[])"
:key="itemIdx"
class="flex items-center gap-3 bg-neutral-800/50 border border-neutral-700/50 rounded-lg p-3"
>
<div class="flex-1 grid grid-cols-2 md:grid-cols-4 gap-3">
<div>
<label class="block text-xs text-neutral-500 mb-1">Short Name</label>
<input
type="text"
:value="item.ShortName"
@input="setKitItemField(itemIdx, 'ShortName', ($event.target as HTMLInputElement).value)"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1.5 text-neutral-200 text-xs"
placeholder="e.g. rifle.ak"
/>
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">Amount</label>
<input
type="number"
:value="item.Amount || 1"
@input="setKitItemField(itemIdx, 'Amount', Number(($event.target as HTMLInputElement).value))"
min="1"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1.5 text-neutral-200 text-xs"
/>
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">Skin ID</label>
<input
type="number"
:value="item.SkinId || 0"
@input="setKitItemField(itemIdx, 'SkinId', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1.5 text-neutral-200 text-xs"
/>
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">Container</label>
<select
:value="item.Container || 'main'"
@change="setKitItemField(itemIdx, 'Container', ($event.target as HTMLSelectElement).value)"
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1.5 text-neutral-200 text-xs"
>
<option value="main">Main</option>
<option value="wear">Wear</option>
<option value="belt">Belt</option>
</select>
</div>
</div>
<button
@click="removeKitItem(itemIdx)"
class="p-1.5 text-neutral-400 hover:text-red-400 transition-colors flex-shrink-0"
title="Remove item"
>
<X class="w-4 h-4" />
</button>
</div>
</div>
</div>
</template>
</div>
<!-- Settings Tab -->
<div v-else-if="activeTab === 'settings'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Global Kit Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Kit chat command -->
<div>
<label class="block text-sm text-neutral-200 mb-1">Kit Chat Command</label>
<p class="text-xs text-neutral-500 mb-2">The chat command players use to access kits</p>
<input
type="text"
:value="getConfigValue('Kit chat command', 'kit')"
@input="setConfigValue('Kit chat command', ($event.target as HTMLInputElement).value)"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<!-- Currency -->
<div>
<label class="block text-sm text-neutral-200 mb-1">Currency for Purchase Costs</label>
<p class="text-xs text-neutral-500 mb-2">Scrap, Economics, or ServerRewards</p>
<select
:value="getConfigValue('Currency used for purchase costs (Scrap, Economics, ServerRewards)', 'Scrap')"
@change="setConfigValue('Currency used for purchase costs (Scrap, Economics, ServerRewards)', ($event.target as HTMLSelectElement).value)"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
>
<option value="Scrap">Scrap</option>
<option value="Economics">Economics</option>
<option value="ServerRewards">ServerRewards</option>
</select>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Log kits given -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Log Kits Given</label>
<p class="text-xs text-neutral-500">Log when kits are claimed by players</p>
</div>
<button
@click="setConfigValue('Log kits given', !getConfigValue('Log kits given', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Log kits given', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Log kits given', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- Wipe data on wipe -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Wipe Player Data on Server Wipe</label>
<p class="text-xs text-neutral-500">Reset kit cooldowns and usage when server wipes</p>
</div>
<button
@click="setConfigValue('Wipe player data when the server is wiped', !getConfigValue('Wipe player data when the server is wiped', true))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Wipe player data when the server is wiped', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Wipe player data when the server is wiped', true) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- Use UI Menu -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Use Kits UI Menu</label>
<p class="text-xs text-neutral-500">Show the in-game kits UI menu to players</p>
</div>
<button
@click="setConfigValue('Use the Kits UI menu', !getConfigValue('Use the Kits UI menu', true))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Use the Kits UI menu', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Use the Kits UI menu', true) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- Allow autokits toggle -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Allow Auto-Kit Toggle</label>
<p class="text-xs text-neutral-500">Let players toggle auto-kits on spawn</p>
</div>
<button
@click="setConfigValue('Allow players to toggle auto-kits on spawn', !getConfigValue('Allow players to toggle auto-kits on spawn', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Allow players to toggle auto-kits on spawn', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Allow players to toggle auto-kits on spawn', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- Show kits without perm -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Show Kits Without Permission</label>
<p class="text-xs text-neutral-500">Show permission-locked kits to players who lack them</p>
</div>
<button
@click="setConfigValue('Show kits with permissions assigned to players without the permission', !getConfigValue('Show kits with permissions assigned to players without the permission', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Show kits with permissions assigned to players without the permission', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Show kits with permissions assigned to players without the permission', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- Admin ignore restrictions -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Admins Ignore Restrictions</label>
<p class="text-xs text-neutral-500">Players with admin perm skip cooldown and usage limits</p>
</div>
<button
@click="setConfigValue('Players with the admin permission ignore usage restrictions', !getConfigValue('Players with the admin permission ignore usage restrictions', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Players with the admin permission ignore usage restrictions', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Players with the admin permission ignore usage restrictions', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
<!-- Auto-kits -->
<div>
<label class="block text-sm text-neutral-200 mb-1">Auto-Kits (ordered by priority)</label>
<p class="text-xs text-neutral-500 mb-2">Comma-separated list of kit names given on respawn</p>
<input
type="text"
:value="(getConfigValue('Autokits ordered by priority', []) as string[]).join(', ')"
@input="setConfigValue('Autokits ordered by priority', ($event.target as HTMLInputElement).value.split(',').map((s: string) => s.trim()).filter((s: string) => s))"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
placeholder="e.g. StarterKit, VIPKit"
/>
</div>
</div>
</div>
<!-- Create Config Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New Kits Config</h2>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="newConfigName"
placeholder="e.g. Default Kits"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleCreateConfig"
/>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
<textarea
v-model="newConfigDesc"
rows="2"
placeholder="What is this config for?"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
/>
</div>
<div class="flex justify-end gap-2">
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="handleCreateConfig"
:disabled="!newConfigName.trim()"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
Create
</button>
</div>
</div>
</div>
</div>
<!-- Import from Server Modal -->
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
<p class="text-sm text-neutral-400 mb-4">
Import the current Kits config from your live server. This will create a new config profile.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="importConfigName"
placeholder="e.g. Imported Server Kits"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleImport"
/>
</div>
<div class="flex justify-end gap-2">
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="handleImport"
:disabled="!importConfigName.trim()"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
Import
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,185 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useLootStore } from '@/stores/loot'
import { useTeleportStore } from '@/stores/teleport'
import { useGatherStore } from '@/stores/gather'
import { useAutoDoorsStore } from '@/stores/autodoors'
import { useKitsStore } from '@/stores/kits'
import { useFurnaceSplitterStore } from '@/stores/furnacesplitter'
import { useBetterChatStore } from '@/stores/betterchat'
import { useTimedExecuteStore } from '@/stores/timedexecute'
import { useRaidableBasesStore } from '@/stores/raidablebases'
import {
Crosshair,
Navigation2,
Pickaxe,
DoorOpen,
Gift,
Flame,
MessageSquare,
Clock,
Swords,
Search,
ArrowRight,
} from 'lucide-vue-next'
const router = useRouter()
const auth = useAuthStore()
const lootStore = useLootStore()
const teleportStore = useTeleportStore()
const gatherStore = useGatherStore()
const autoDoorsStore = useAutoDoorsStore()
const kitsStore = useKitsStore()
const furnaceSplitterStore = useFurnaceSplitterStore()
const betterChatStore = useBetterChatStore()
const timedExecuteStore = useTimedExecuteStore()
const raidableBasesStore = useRaidableBasesStore()
const searchQuery = ref('')
const loading = ref(true)
interface PluginDef {
key: string
name: string
description: string
icon: any
path: string
permission: string
getConfigs: () => any[]
fetchFn: () => Promise<void>
}
const plugins: PluginDef[] = [
{ key: 'loot', name: 'Loot Tables', description: 'Configure loot container drop tables and item probabilities', icon: Crosshair, path: '/loot-builder', permission: 'loot.view', getConfigs: () => lootStore.profiles, fetchFn: () => lootStore.fetchProfiles() },
{ key: 'teleport', name: 'Teleport', description: 'Home locations, TPR cooldowns, and VIP teleport settings', icon: Navigation2, path: '/teleport-config', permission: 'teleport.view', getConfigs: () => teleportStore.configs, fetchFn: () => teleportStore.fetchConfigs() },
{ key: 'gather', name: 'Gather Rates', description: 'Resource gathering multipliers and pickup rates', icon: Pickaxe, path: '/gather-manager', permission: 'gather.view', getConfigs: () => gatherStore.configs, fetchFn: () => gatherStore.fetchConfigs() },
{ key: 'autodoors', name: 'Auto Doors', description: 'Automatic door closing delays and permissions', icon: DoorOpen, path: '/autodoors', permission: 'autodoors.view', getConfigs: () => autoDoorsStore.configs, fetchFn: () => autoDoorsStore.fetchConfigs() },
{ key: 'kits', name: 'Kits', description: 'Player kits with items, cooldowns, and permissions', icon: Gift, path: '/kits', permission: 'kits.view', getConfigs: () => kitsStore.configs, fetchFn: () => kitsStore.fetchConfigs() },
{ key: 'furnacesplitter', name: 'Furnace Splitter', description: 'Automatic furnace ore splitting and smelting config', icon: Flame, path: '/furnace-splitter', permission: 'furnacesplitter.view', getConfigs: () => furnaceSplitterStore.configs, fetchFn: () => furnaceSplitterStore.fetchConfigs() },
{ key: 'betterchat', name: 'Better Chat', description: 'Chat formatting, group colors, and title prefixes', icon: MessageSquare, path: '/better-chat', permission: 'betterchat.view', getConfigs: () => betterChatStore.configs, fetchFn: () => betterChatStore.fetchConfigs() },
{ key: 'timedexecute', name: 'Timed Execute', description: 'Scheduled, real-time, and event-driven command execution', icon: Clock, path: '/timed-execute', permission: 'timedexecute.view', getConfigs: () => timedExecuteStore.configs, fetchFn: () => timedExecuteStore.fetchConfigs() },
{ key: 'raidablebases', name: 'Raidable Bases', description: 'PVE raid events, difficulty, NPCs, and loot settings', icon: Swords, path: '/raidable-bases', permission: 'raidablebases.view', getConfigs: () => raidableBasesStore.configs, fetchFn: () => raidableBasesStore.fetchConfigs() },
]
const visiblePlugins = computed(() =>
plugins.filter(p => auth.hasPermission(p.permission))
)
const filteredPlugins = computed(() => {
if (!searchQuery.value.trim()) return visiblePlugins.value
const q = searchQuery.value.toLowerCase()
return visiblePlugins.value.filter(
p => p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q)
)
})
function getStatus(plugin: PluginDef): { label: string; color: string } {
const configs = plugin.getConfigs()
if (!configs || configs.length === 0) return { label: 'Not Configured', color: 'neutral' }
const hasActive = configs.some((c: any) => c.is_active)
if (hasActive) return { label: 'Active', color: 'green' }
return { label: 'Configured', color: 'blue' }
}
function getConfigCount(plugin: PluginDef): string {
const configs = plugin.getConfigs()
if (!configs || configs.length === 0) return 'No profiles'
return `${configs.length} profile${configs.length !== 1 ? 's' : ''}`
}
onMounted(async () => {
const fetches = visiblePlugins.value.map(p => p.fetchFn().catch(() => {}))
await Promise.all(fetches)
loading.value = false
})
</script>
<template>
<div class="p-6 max-w-7xl mx-auto">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-white">Plugin Configs</h1>
<p class="text-neutral-400 mt-1">Configure and manage your server plugins</p>
</div>
<!-- Search -->
<div class="relative mb-6">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
<input
v-model="searchQuery"
type="text"
placeholder="Search plugins..."
class="w-full pl-10 pr-4 py-2 bg-neutral-900 border border-neutral-800 rounded-lg text-sm text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-oxide-500 transition-colors"
/>
</div>
<!-- Loading -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="i in visiblePlugins.length"
:key="i"
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 animate-pulse"
>
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 bg-neutral-800 rounded-lg" />
<div class="flex-1">
<div class="h-4 w-24 bg-neutral-800 rounded" />
<div class="h-3 w-16 bg-neutral-800 rounded mt-2" />
</div>
</div>
<div class="h-3 w-full bg-neutral-800 rounded mt-3" />
<div class="h-3 w-2/3 bg-neutral-800 rounded mt-2" />
</div>
</div>
<!-- Cards -->
<div v-else-if="filteredPlugins.length" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="plugin in filteredPlugins"
:key="plugin.key"
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 hover:border-neutral-700 transition-colors group"
>
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-neutral-800 rounded-lg flex items-center justify-center group-hover:bg-oxide-500/10 transition-colors">
<component :is="plugin.icon" class="w-5 h-5 text-neutral-400 group-hover:text-oxide-400 transition-colors" />
</div>
<div>
<h3 class="text-sm font-semibold text-white">{{ plugin.name }}</h3>
<span class="text-xs text-neutral-500">{{ getConfigCount(plugin) }}</span>
</div>
</div>
<!-- Status badge -->
<span
class="text-[10px] font-medium px-2 py-0.5 rounded-full"
:class="{
'bg-green-500/10 text-green-400': getStatus(plugin).color === 'green',
'bg-blue-500/10 text-blue-400': getStatus(plugin).color === 'blue',
'bg-neutral-800 text-neutral-500': getStatus(plugin).color === 'neutral',
}"
>
{{ getStatus(plugin).label }}
</span>
</div>
<p class="text-xs text-neutral-400 mb-4 line-clamp-2">{{ plugin.description }}</p>
<button
@click="router.push(plugin.path)"
class="w-full flex items-center justify-center gap-2 px-3 py-2 bg-neutral-800 hover:bg-oxide-500/10 text-neutral-300 hover:text-oxide-400 text-xs font-medium rounded-lg transition-colors"
>
Configure
<ArrowRight class="w-3 h-3" />
</button>
</div>
</div>
<!-- Empty state -->
<div v-else class="text-center py-12">
<p class="text-neutral-500">No plugins match your search.</p>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ import {
Rocket,
AlertTriangle,
Check,
Puzzle,
} from 'lucide-vue-next'
import type { DeploymentConfig, DeploymentStatus } from '@/types'
import { useWebSocket } from '@/composables/useWebSocket'
@@ -34,6 +35,8 @@ const setupTab = ref<'linux' | 'windows'>('linux')
const windowsCopied = ref(false)
const showDeployForm = ref(false)
const deployLoading = ref(false)
const oxideStatus = ref<{ stage: string; progress: number; message: string; error?: string } | null>(null)
const isInstallingOxide = ref(false)
const deployForm = ref<DeploymentConfig>({
server_name: 'My Rust Server',
@@ -141,6 +144,42 @@ function getStageState(stageKey: string): 'pending' | 'active' | 'complete' | 'f
return 'pending'
}
const oxideStages = [
{ key: 'fetching_release', label: 'Check Latest Release' },
{ key: 'downloading', label: 'Download Oxide' },
{ key: 'installing', label: 'Extract Files' },
{ key: 'restarting', label: 'Restart Server' },
{ key: 'complete', label: 'Complete' },
] as const
function getOxideStageState(stageKey: string): 'pending' | 'active' | 'complete' | 'failed' {
if (!oxideStatus.value) return 'pending'
const status = oxideStatus.value
if (status.stage === 'failed') {
const currentStages = oxideStages
const idx = currentStages.findIndex(s => s.key === stageKey)
// Find which stage was active when failure occurred — approximate from message
// For failed state, mark all stages before current as complete
return idx === 0 ? 'failed' : 'pending'
}
const currentIdx = oxideStages.findIndex(s => s.key === status.stage)
const thisIdx = oxideStages.findIndex(s => s.key === stageKey)
if (thisIdx < currentIdx) return 'complete'
if (thisIdx === currentIdx) return status.stage === 'complete' ? 'complete' : 'active'
return 'pending'
}
async function installOxide() {
isInstallingOxide.value = true
oxideStatus.value = null
try {
await server.installOxide()
} catch {
toast.error('Failed to start Oxide installation')
isInstallingOxide.value = false
}
}
const form = ref({
server_name: '',
max_players: 0,
@@ -207,6 +246,12 @@ onMounted(async () => {
if (msg.type === 'event' && msg.event === 'deploy_status') {
server.updateDeploymentStatus(msg.data as DeploymentStatus)
}
if (msg.type === 'event' && msg.event === 'oxide_status') {
oxideStatus.value = msg.data as { stage: string; progress: number; message: string; error?: string }
if (msg.data && (msg.data as any).stage === 'complete' || (msg.data as any).stage === 'failed') {
isInstallingOxide.value = false
}
}
})
})
</script>
@@ -544,6 +589,82 @@ onMounted(async () => {
</div>
</div>
<!-- Install Oxide/uMod -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-5">
<Puzzle class="w-4 h-4 text-oxide-400" />
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Install Oxide / uMod</h2>
</div>
<!-- Installation Progress Tracker -->
<div v-if="oxideStatus || isInstallingOxide" class="mb-6">
<div class="space-y-3">
<div
v-for="stage in oxideStages"
:key="stage.key"
class="flex items-center gap-3"
>
<!-- Stage indicator -->
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
:class="{
'bg-neutral-800 text-neutral-600': getOxideStageState(stage.key) === 'pending',
'bg-amber-500/20 text-amber-400': getOxideStageState(stage.key) === 'active',
'bg-green-500/20 text-green-400': getOxideStageState(stage.key) === 'complete',
'bg-red-500/20 text-red-400': getOxideStageState(stage.key) === 'failed',
}"
>
<Loader2 v-if="getOxideStageState(stage.key) === 'active'" class="w-3.5 h-3.5 animate-spin" />
<Check v-else-if="getOxideStageState(stage.key) === 'complete'" class="w-3.5 h-3.5" />
<AlertTriangle v-else-if="getOxideStageState(stage.key) === 'failed'" class="w-3.5 h-3.5" />
<span v-else class="w-1.5 h-1.5 rounded-full bg-neutral-600" />
</div>
<!-- Stage label -->
<span
class="text-sm"
:class="{
'text-neutral-600': getOxideStageState(stage.key) === 'pending',
'text-amber-300 font-medium': getOxideStageState(stage.key) === 'active',
'text-green-400': getOxideStageState(stage.key) === 'complete',
'text-red-400': getOxideStageState(stage.key) === 'failed',
}"
>{{ stage.label }}</span>
</div>
</div>
<!-- Status message -->
<div v-if="oxideStatus?.message" class="mt-4 px-3 py-2 bg-neutral-800/50 rounded-lg">
<p class="text-xs text-neutral-400">{{ oxideStatus.message }}</p>
</div>
<!-- Error display -->
<div v-if="oxideStatus?.error" class="mt-3 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg">
<p class="text-xs text-red-400">{{ oxideStatus.error }}</p>
</div>
<!-- Retry button on failure -->
<button
v-if="oxideStatus?.stage === 'failed'"
@click="oxideStatus = null; installOxide()"
class="mt-3 flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<RotateCcw class="w-4 h-4" />
Retry Installation
</button>
</div>
<!-- Install Button (shown when not installing) -->
<div v-else class="text-center py-4">
<p class="text-sm text-neutral-400 mb-4">Install or update Oxide/uMod. Required for all plugins including CorrosionCompanion.</p>
<button
@click="installOxide()"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<Puzzle class="w-4 h-4" />
Install Oxide
</button>
</div>
</div>
<!-- Configuration -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center justify-between mb-4">

View File

@@ -0,0 +1,691 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useTeleportStore } from '@/stores/teleport'
import PermissionGroupEditor from '@/components/teleport/PermissionGroupEditor.vue'
import {
Save,
Play,
Download,
Plus,
Trash2,
Navigation2,
Home,
Users,
Settings as SettingsIcon,
} from 'lucide-vue-next'
const store = useTeleportStore()
const activeTab = ref<'general' | 'homes' | 'tpr' | 'vip'>('general')
const showCreateModal = ref(false)
const showImportModal = ref(false)
const newConfigName = ref('')
const newConfigDesc = ref('')
const importConfigName = ref('')
const tabs = [
{ key: 'general', label: 'General', icon: SettingsIcon },
{ key: 'homes', label: 'Homes', icon: Home },
{ key: 'tpr', label: 'TPR', icon: Navigation2 },
{ key: 'vip', label: 'VIP Groups', icon: Users },
]
onMounted(async () => {
await store.fetchConfigs()
if (store.configs.length > 0 && store.configs[0]) {
await store.loadConfig(store.configs[0].id)
}
})
// --- Config data helpers ---
function getConfigValue(path: string, defaultValue: any = false): any {
if (!store.currentConfig?.config_data) return defaultValue
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (const part of parts) {
if (current == null || typeof current !== 'object') return defaultValue
current = current[part]
}
return current ?? defaultValue
}
function setConfigValue(path: string, value: any) {
if (!store.currentConfig) return
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!
if (current[part] == null || typeof current[part] !== 'object') {
current[part] = {}
}
current = current[part]
}
current[parts[parts.length - 1]!] = value
store.markDirty()
}
// --- Action handlers ---
async function handleConfigChange(id: string) {
if (store.isDirty) {
if (!confirm('Unsaved changes will be lost. Continue?')) return
}
await store.loadConfig(id)
}
async function handleCreateConfig() {
if (!newConfigName.value.trim()) return
const config = await store.createConfig(
newConfigName.value.trim(),
newConfigDesc.value.trim() || undefined,
)
if (config) {
showCreateModal.value = false
newConfigName.value = ''
newConfigDesc.value = ''
}
}
async function handleDeleteConfig() {
if (!store.currentConfig) return
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
await store.deleteConfig(store.currentConfig.id)
}
async function handleApply() {
if (!store.currentConfig) return
if (!confirm('Apply this teleport config to the server? This will overwrite the current NTeleportation config.')) return
if (store.isDirty) {
await store.saveCurrentConfig()
}
await store.applyToServer(store.currentConfig.id)
}
async function handleImport() {
if (!importConfigName.value.trim()) return
const config = await store.importFromServer(importConfigName.value.trim())
if (config) {
showImportModal.value = false
importConfigName.value = ''
}
}
function handlePermissionGroupUpdate(updatedData: Record<string, any>) {
if (!store.currentConfig) return
store.currentConfig.config_data = updatedData
store.markDirty()
}
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">Teleport Config</h1>
<div class="flex items-center gap-3">
<button
@click="showCreateModal = true"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Plus class="w-4 h-4" />
New Config
</button>
</div>
</div>
<!-- Config Selector + Action Bar -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div class="flex items-center gap-3 flex-wrap">
<!-- Config Selector -->
<select
v-if="store.configs.length > 0"
:value="store.currentConfig?.id || ''"
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
>
<option v-for="c in store.configs" :key="c.id" :value="c.id">
{{ c.config_name }}
<template v-if="c.is_active"> (Active)</template>
</option>
</select>
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
<!-- Save -->
<button
@click="store.saveCurrentConfig()"
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Save class="w-4 h-4" />
{{ store.isSaving ? 'Saving...' : 'Save' }}
</button>
<!-- Apply to Server -->
<button
@click="handleApply"
:disabled="!store.currentConfig || store.isApplying"
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Play class="w-4 h-4" />
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
</button>
<!-- Import from Server -->
<button
@click="showImportModal = true"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Download class="w-4 h-4" />
Import from Server
</button>
<!-- Delete -->
<button
@click="handleDeleteConfig"
:disabled="!store.currentConfig"
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
>
<Trash2 class="w-4 h-4" />
Delete
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
</div>
<!-- No Config Selected -->
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
<Navigation2 class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Teleport Config Selected</h2>
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
<button
@click="showCreateModal = true"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
>
Create First Config
</button>
</div>
<!-- Config Editor -->
<div v-else class="space-y-6">
<!-- Tab Bar -->
<div class="flex border-b border-neutral-800">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key as typeof activeTab"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === tab.key
? 'border-oxide-500 text-oxide-400'
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
>
<component :is="tab.icon" class="w-4 h-4" />
{{ tab.label }}
</button>
</div>
<!-- General Tab -->
<div v-if="activeTab === 'general'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">General Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- UseEconomics -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Use Economics</label>
<p class="text-xs text-neutral-500">Charge players for teleports via Economics plugin</p>
</div>
<button
@click="setConfigValue('Settings.UseEconomics', !getConfigValue('Settings.UseEconomics', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Settings.UseEconomics', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Settings.UseEconomics', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- UseServerRewards -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Use Server Rewards</label>
<p class="text-xs text-neutral-500">Charge players via ServerRewards plugin</p>
</div>
<button
@click="setConfigValue('Settings.UseServerRewards', !getConfigValue('Settings.UseServerRewards', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Settings.UseServerRewards', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Settings.UseServerRewards', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- CheckBoundaries -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Cave/Water boundary checks</label>
<p class="text-xs text-neutral-500">Prevent teleporting into caves or underwater</p>
</div>
<button
@click="setConfigValue('Settings.CheckBoundaries', !getConfigValue('Settings.CheckBoundaries', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Settings.CheckBoundaries', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Settings.CheckBoundaries', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- InterruptTPOnHostile -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Cancel TP if hostile timer</label>
<p class="text-xs text-neutral-500">Cancel pending teleport if player becomes hostile</p>
</div>
<button
@click="setConfigValue('Settings.InterruptTPOnHostile', !getConfigValue('Settings.InterruptTPOnHostile', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Settings.InterruptTPOnHostile', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Settings.InterruptTPOnHostile', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- WipeHomesOnUpgrade -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Wipe homes on map update</label>
<p class="text-xs text-neutral-500">Clear all home locations when the map changes</p>
</div>
<button
@click="setConfigValue('Settings.WipeHomesOnUpgrade', !getConfigValue('Settings.WipeHomesOnUpgrade', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Settings.WipeHomesOnUpgrade', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Settings.WipeHomesOnUpgrade', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- PlayersOnlyCannotTeleport -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Players Only Cannot Teleport</label>
<p class="text-xs text-neutral-500">Restrict teleport to specific player groups only</p>
</div>
<button
@click="setConfigValue('Settings.PlayersOnlyCannotTeleport', !getConfigValue('Settings.PlayersOnlyCannotTeleport', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Settings.PlayersOnlyCannotTeleport', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Settings.PlayersOnlyCannotTeleport', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
<!-- Global Cooldown (number) -->
<div class="max-w-sm">
<label class="block text-sm text-neutral-200 mb-1">Global cooldown (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">Minimum time between any teleport commands</p>
<input
type="number"
:value="getConfigValue('Settings.GlobalTeleportCooldown', 0)"
@input="setConfigValue('Settings.GlobalTeleportCooldown', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
</div>
<!-- Homes Tab -->
<div v-else-if="activeTab === 'homes'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Home Teleport Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- UsableOutOfBuildingBlocked -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Can use outside building privilege</label>
<p class="text-xs text-neutral-500">Allow home teleport even without building privilege</p>
</div>
<button
@click="setConfigValue('Home.UsableOutOfBuildingBlocked', !getConfigValue('Home.UsableOutOfBuildingBlocked', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Home.UsableOutOfBuildingBlocked', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Home.UsableOutOfBuildingBlocked', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- ForceOnTopOfFoundation -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Force home on foundation</label>
<p class="text-xs text-neutral-500">Homes can only be set on a foundation block</p>
</div>
<button
@click="setConfigValue('Home.ForceOnTopOfFoundation', !getConfigValue('Home.ForceOnTopOfFoundation', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Home.ForceOnTopOfFoundation', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Home.ForceOnTopOfFoundation', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- CheckFoundationForOwner -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Verify foundation ownership</label>
<p class="text-xs text-neutral-500">Only allow homes on foundations the player owns</p>
</div>
<button
@click="setConfigValue('Home.CheckFoundationForOwner', !getConfigValue('Home.CheckFoundationForOwner', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Home.CheckFoundationForOwner', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Home.CheckFoundationForOwner', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- AllowAboveFoundation -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Allow Above Foundation</label>
<p class="text-xs text-neutral-500">Allow setting homes above foundation level</p>
</div>
<button
@click="setConfigValue('Home.AllowAboveFoundation', !getConfigValue('Home.AllowAboveFoundation', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Home.AllowAboveFoundation', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Home.AllowAboveFoundation', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- CupOwnerAllowOnBuildingBlocked -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Cupboard Owner Allow on Building Blocked</label>
<p class="text-xs text-neutral-500">Allow TC owners to teleport even when building blocked</p>
</div>
<button
@click="setConfigValue('Home.CupOwnerAllowOnBuildingBlocked', !getConfigValue('Home.CupOwnerAllowOnBuildingBlocked', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('Home.CupOwnerAllowOnBuildingBlocked', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('Home.CupOwnerAllowOnBuildingBlocked', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
<!-- Number Inputs -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label class="block text-sm text-neutral-200 mb-1">Homes Limit</label>
<p class="text-xs text-neutral-500 mb-2">Default max homes per player</p>
<input
type="number"
:value="getConfigValue('Home.HomesLimit', 3)"
@input="setConfigValue('Home.HomesLimit', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<div>
<label class="block text-sm text-neutral-200 mb-1">Daily Limit</label>
<p class="text-xs text-neutral-500 mb-2">Max home teleports per day</p>
<input
type="number"
:value="getConfigValue('Home.DefaultDailyLimit', 5)"
@input="setConfigValue('Home.DefaultDailyLimit', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<div>
<label class="block text-sm text-neutral-200 mb-1">Cooldown (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">Time between home teleports</p>
<input
type="number"
:value="getConfigValue('Home.DefaultCooldown', 600)"
@input="setConfigValue('Home.DefaultCooldown', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<div>
<label class="block text-sm text-neutral-200 mb-1">Countdown (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">Countdown before teleport</p>
<input
type="number"
:value="getConfigValue('Home.DefaultCountdown', 5)"
@input="setConfigValue('Home.DefaultCountdown', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
</div>
</div>
<!-- TPR Tab -->
<div v-else-if="activeTab === 'tpr'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Teleport Request Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- BlockTPAOnCeiling -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Block TP accept on ceiling</label>
<p class="text-xs text-neutral-500">Prevent accepting a TP while on a ceiling tile</p>
</div>
<button
@click="setConfigValue('TPR.BlockTPAOnCeiling', !getConfigValue('TPR.BlockTPAOnCeiling', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('TPR.BlockTPAOnCeiling', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('TPR.BlockTPAOnCeiling', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- OffsetTPRTarget -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Offset teleport target position</label>
<p class="text-xs text-neutral-500">Slightly offset the teleport landing position</p>
</div>
<button
@click="setConfigValue('TPR.OffsetTPRTarget', !getConfigValue('TPR.OffsetTPRTarget', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('TPR.OffsetTPRTarget', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('TPR.OffsetTPRTarget', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<!-- AutoAcceptEnabled -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-neutral-200">Auto Accept Enabled</label>
<p class="text-xs text-neutral-500">Automatically accept incoming TP requests</p>
</div>
<button
@click="setConfigValue('TPR.AutoAcceptEnabled', !getConfigValue('TPR.AutoAcceptEnabled', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('TPR.AutoAcceptEnabled', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('TPR.AutoAcceptEnabled', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
<!-- Number Inputs -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label class="block text-sm text-neutral-200 mb-1">Cooldown (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">Cooldown between TPR requests</p>
<input
type="number"
:value="getConfigValue('TPR.Cooldown', 600)"
@input="setConfigValue('TPR.Cooldown', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<div>
<label class="block text-sm text-neutral-200 mb-1">Countdown (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">Countdown before teleport</p>
<input
type="number"
:value="getConfigValue('TPR.Countdown', 5)"
@input="setConfigValue('TPR.Countdown', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<div>
<label class="block text-sm text-neutral-200 mb-1">Daily Limit</label>
<p class="text-xs text-neutral-500 mb-2">Max TPR per day</p>
<input
type="number"
:value="getConfigValue('TPR.DailyLimit', 5)"
@input="setConfigValue('TPR.DailyLimit', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
<div>
<label class="block text-sm text-neutral-200 mb-1">Request Duration (seconds)</label>
<p class="text-xs text-neutral-500 mb-2">How long a TPR request lasts</p>
<input
type="number"
:value="getConfigValue('TPR.RequestDuration', 30)"
@input="setConfigValue('TPR.RequestDuration', Number(($event.target as HTMLInputElement).value))"
min="0"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
/>
</div>
</div>
</div>
<!-- VIP Groups Tab -->
<div v-else-if="activeTab === 'vip'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6">
<PermissionGroupEditor
:config-data="store.currentConfig.config_data"
@update:config-data="handlePermissionGroupUpdate"
/>
</div>
</div>
<!-- Create Config Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New Teleport Config</h2>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="newConfigName"
placeholder="e.g. Default TP Settings"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleCreateConfig"
/>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
<textarea
v-model="newConfigDesc"
rows="2"
placeholder="What is this config for?"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
/>
</div>
<div class="flex justify-end gap-2">
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="handleCreateConfig"
:disabled="!newConfigName.trim()"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
Create
</button>
</div>
</div>
</div>
</div>
<!-- Import from Server Modal -->
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
<p class="text-sm text-neutral-400 mb-4">
Import the current NTeleportation config from your live server. This will create a new config profile.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="importConfigName"
placeholder="e.g. Imported Server Config"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleImport"
/>
</div>
<div class="flex justify-end gap-2">
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="handleImport"
:disabled="!importConfigName.trim()"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
Import
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,655 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useTimedExecuteStore } from '@/stores/timedexecute'
import {
Save,
Play,
Download,
Plus,
Trash2,
Clock,
Settings as SettingsIcon,
UserPlus,
UserMinus,
} from 'lucide-vue-next'
const store = useTimedExecuteStore()
const activeTab = ref<'timed' | 'realtime' | 'connect' | 'disconnect'>('timed')
const showCreateModal = ref(false)
const showImportModal = ref(false)
const newConfigName = ref('')
const newConfigDesc = ref('')
const importConfigName = ref('')
const tabs = [
{ key: 'timed', label: 'Timed Commands', icon: Clock },
{ key: 'realtime', label: 'Real-Time', icon: SettingsIcon },
{ key: 'connect', label: 'On Connect', icon: UserPlus },
{ key: 'disconnect', label: 'On Disconnect', icon: UserMinus },
]
onMounted(async () => {
await store.fetchConfigs()
if (store.configs.length > 0 && store.configs[0]) {
await store.loadConfig(store.configs[0].id)
}
})
// --- Config data helpers ---
function getConfigValue(path: string, defaultValue: any = false): any {
if (!store.currentConfig?.config_data) return defaultValue
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (const part of parts) {
if (current == null || typeof current !== 'object') return defaultValue
current = current[part]
}
return current ?? defaultValue
}
function setConfigValue(path: string, value: any) {
if (!store.currentConfig) return
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
const parts = path.split('.')
let current: any = store.currentConfig.config_data
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!
if (current[part] == null || typeof current[part] !== 'object') {
current[part] = {}
}
current = current[part]
}
current[parts[parts.length - 1]!] = value
store.markDirty()
}
// --- TimerRepeat helpers ---
const timerRepeatEntries = computed(() => {
const obj = getConfigValue('TimerRepeat', {}) as Record<string, number>
return Object.entries(obj).map(([command, interval]) => ({ command, interval }))
})
function addTimerRepeat() {
const obj = { ...getConfigValue('TimerRepeat', {}) as Record<string, number> }
obj['new.command'] = 300
setConfigValue('TimerRepeat', obj)
}
function updateTimerRepeatCommand(oldCmd: string, newCmd: string) {
const obj = { ...getConfigValue('TimerRepeat', {}) as Record<string, number> }
const interval = obj[oldCmd] ?? 300
delete obj[oldCmd]
obj[newCmd] = interval
setConfigValue('TimerRepeat', obj)
}
function updateTimerRepeatInterval(cmd: string, interval: number) {
const obj = { ...getConfigValue('TimerRepeat', {}) as Record<string, number> }
obj[cmd] = interval
setConfigValue('TimerRepeat', obj)
}
function removeTimerRepeat(cmd: string) {
const obj = { ...getConfigValue('TimerRepeat', {}) as Record<string, number> }
delete obj[cmd]
setConfigValue('TimerRepeat', obj)
}
function addPresetTimer(command: string, interval: number) {
const obj = { ...getConfigValue('TimerRepeat', {}) as Record<string, number> }
obj[command] = interval
setConfigValue('TimerRepeat', obj)
}
// --- RealTime-Timer helpers ---
const realTimeEntries = computed(() => {
const obj = getConfigValue('RealTime-Timer', {}) as Record<string, string>
return Object.entries(obj).map(([time, command]) => ({ time, command }))
})
function addRealTimeEntry() {
const obj = { ...getConfigValue('RealTime-Timer', {}) as Record<string, string> }
obj['12:00:00'] = 'say Scheduled message'
setConfigValue('RealTime-Timer', obj)
}
function updateRealTimeTime(oldTime: string, newTime: string) {
const obj = { ...getConfigValue('RealTime-Timer', {}) as Record<string, string> }
const command = obj[oldTime] ?? ''
delete obj[oldTime]
obj[newTime] = command
setConfigValue('RealTime-Timer', obj)
}
function updateRealTimeCommand(time: string, command: string) {
const obj = { ...getConfigValue('RealTime-Timer', {}) as Record<string, string> }
obj[time] = command
setConfigValue('RealTime-Timer', obj)
}
function removeRealTimeEntry(time: string) {
const obj = { ...getConfigValue('RealTime-Timer', {}) as Record<string, string> }
delete obj[time]
setConfigValue('RealTime-Timer', obj)
}
// --- OnPlayerConnected helpers ---
const connectCommands = computed(() => {
return (getConfigValue('OnPlayerConnectCommands', []) as string[])
})
function addConnectCommand() {
const cmds = [...connectCommands.value, '']
setConfigValue('OnPlayerConnectCommands', cmds)
}
function updateConnectCommand(index: number, value: string) {
const cmds = [...connectCommands.value]
cmds[index] = value
setConfigValue('OnPlayerConnectCommands', cmds)
}
function removeConnectCommand(index: number) {
const cmds = [...connectCommands.value]
cmds.splice(index, 1)
setConfigValue('OnPlayerConnectCommands', cmds)
}
// --- OnPlayerDisconnected helpers ---
const disconnectCommands = computed(() => {
return (getConfigValue('OnPlayerDisconnectCommands', []) as string[])
})
function addDisconnectCommand() {
const cmds = [...disconnectCommands.value, '']
setConfigValue('OnPlayerDisconnectCommands', cmds)
}
function updateDisconnectCommand(index: number, value: string) {
const cmds = [...disconnectCommands.value]
cmds[index] = value
setConfigValue('OnPlayerDisconnectCommands', cmds)
}
function removeDisconnectCommand(index: number) {
const cmds = [...disconnectCommands.value]
cmds.splice(index, 1)
setConfigValue('OnPlayerDisconnectCommands', cmds)
}
// --- Action handlers ---
async function handleConfigChange(id: string) {
if (store.isDirty) {
if (!confirm('Unsaved changes will be lost. Continue?')) return
}
await store.loadConfig(id)
}
async function handleCreateConfig() {
if (!newConfigName.value.trim()) return
const config = await store.createConfig(
newConfigName.value.trim(),
newConfigDesc.value.trim() || undefined,
)
if (config) {
showCreateModal.value = false
newConfigName.value = ''
newConfigDesc.value = ''
}
}
async function handleDeleteConfig() {
if (!store.currentConfig) return
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
await store.deleteConfig(store.currentConfig.id)
}
async function handleApply() {
if (!store.currentConfig) return
if (!confirm('Apply this TimedExecute config to the server? This will overwrite the current config.')) return
if (store.isDirty) {
await store.saveCurrentConfig()
}
await store.applyToServer(store.currentConfig.id)
}
async function handleImport() {
if (!importConfigName.value.trim()) return
const config = await store.importFromServer(importConfigName.value.trim())
if (config) {
showImportModal.value = false
importConfigName.value = ''
}
}
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">Timed Execute</h1>
<div class="flex items-center gap-3">
<button
@click="showCreateModal = true"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Plus class="w-4 h-4" />
New Config
</button>
</div>
</div>
<!-- Config Selector + Action Bar -->
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div class="flex items-center gap-3 flex-wrap">
<!-- Config Selector -->
<select
v-if="store.configs.length > 0"
:value="store.currentConfig?.id || ''"
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
>
<option v-for="c in store.configs" :key="c.id" :value="c.id">
{{ c.config_name }}
<template v-if="c.is_active"> (Active)</template>
</option>
</select>
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
<!-- Save -->
<button
@click="store.saveCurrentConfig()"
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Save class="w-4 h-4" />
{{ store.isSaving ? 'Saving...' : 'Save' }}
</button>
<!-- Apply to Server -->
<button
@click="handleApply"
:disabled="!store.currentConfig || store.isApplying"
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Play class="w-4 h-4" />
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
</button>
<!-- Import from Server -->
<button
@click="showImportModal = true"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Download class="w-4 h-4" />
Import from Server
</button>
<!-- Delete -->
<button
@click="handleDeleteConfig"
:disabled="!store.currentConfig"
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
>
<Trash2 class="w-4 h-4" />
Delete
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
</div>
<!-- No Config Selected -->
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
<Clock class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No TimedExecute Config Selected</h2>
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
<button
@click="showCreateModal = true"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
>
Create First Config
</button>
</div>
<!-- Config Editor -->
<div v-else class="space-y-6">
<!-- Tab Bar -->
<div class="flex border-b border-neutral-800">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key as typeof activeTab"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === tab.key
? 'border-oxide-500 text-oxide-400'
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
>
<component :is="tab.icon" class="w-4 h-4" />
{{ tab.label }}
</button>
</div>
<!-- Timed Commands Tab -->
<div v-if="activeTab === 'timed'" class="space-y-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Timer Repeat</h3>
<p class="text-xs text-neutral-500 mt-1">Commands executed repeatedly at set intervals (seconds)</p>
</div>
<div class="flex items-center gap-2">
<!-- Enable toggle -->
<span class="text-xs text-neutral-400 mr-2">Enabled</span>
<button
@click="setConfigValue('EnableTimerRepeat', !getConfigValue('EnableTimerRepeat', true))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('EnableTimerRepeat', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('EnableTimerRepeat', true) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
<!-- Preset Buttons -->
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-neutral-500">Quick add:</span>
<button
@click="addPresetTimer('server.save', 300)"
class="px-2 py-1 text-xs bg-neutral-800 text-neutral-300 rounded hover:bg-neutral-700"
>
server.save (5m)
</button>
<button
@click="addPresetTimer('say Server restart warning!', 3600)"
class="px-2 py-1 text-xs bg-neutral-800 text-neutral-300 rounded hover:bg-neutral-700"
>
Restart warning (1h)
</button>
<button
@click="addPresetTimer('oxide.reload *', 7200)"
class="px-2 py-1 text-xs bg-neutral-800 text-neutral-300 rounded hover:bg-neutral-700"
>
Reload plugins (2h)
</button>
</div>
<!-- Entries -->
<div v-if="timerRepeatEntries.length === 0" class="py-4 text-center text-neutral-500 text-sm">
No timed commands configured. Add a command or use a preset above.
</div>
<div v-else class="space-y-2">
<div
v-for="(entry, index) in timerRepeatEntries"
:key="index"
class="flex items-center gap-3 bg-neutral-800/50 rounded-lg p-3"
>
<input
:value="entry.command"
@change="updateTimerRepeatCommand(entry.command, ($event.target as HTMLInputElement).value)"
placeholder="console command"
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
/>
<div class="flex items-center gap-1">
<input
:value="entry.interval"
@input="updateTimerRepeatInterval(entry.command, Number(($event.target as HTMLInputElement).value))"
type="number"
min="1"
class="w-24 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-neutral-200 text-sm text-right"
/>
<span class="text-xs text-neutral-500">sec</span>
</div>
<button
@click="removeTimerRepeat(entry.command)"
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
<button
@click="addTimerRepeat"
class="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Plus class="w-4 h-4" />
Add Command
</button>
</div>
</div>
<!-- Real-Time Tab -->
<div v-else-if="activeTab === 'realtime'" class="space-y-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Real-Time Timer</h3>
<p class="text-xs text-neutral-500 mt-1">Commands executed at specific times of day (HH:MM:SS)</p>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-neutral-400 mr-2">Enabled</span>
<button
@click="setConfigValue('EnableRealTime-Timer', !getConfigValue('EnableRealTime-Timer', false))"
class="relative w-11 h-6 rounded-full transition-colors"
:class="getConfigValue('EnableRealTime-Timer', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
:class="getConfigValue('EnableRealTime-Timer', false) ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
<div v-if="realTimeEntries.length === 0" class="py-4 text-center text-neutral-500 text-sm">
No real-time commands configured. Add a time-based command below.
</div>
<div v-else class="space-y-2">
<div
v-for="(entry, index) in realTimeEntries"
:key="index"
class="flex items-center gap-3 bg-neutral-800/50 rounded-lg p-3"
>
<input
:value="entry.time"
@change="updateRealTimeTime(entry.time, ($event.target as HTMLInputElement).value)"
placeholder="HH:MM:SS"
class="w-32 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
/>
<input
:value="entry.command"
@change="updateRealTimeCommand(entry.time, ($event.target as HTMLInputElement).value)"
placeholder="console command"
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
/>
<button
@click="removeRealTimeEntry(entry.time)"
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
<button
@click="addRealTimeEntry"
class="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Plus class="w-4 h-4" />
Add Time Entry
</button>
</div>
</div>
<!-- On Connect Tab -->
<div v-else-if="activeTab === 'connect'" class="space-y-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<div>
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">On Player Connect</h3>
<p class="text-xs text-neutral-500 mt-1">Commands executed when a player joins the server</p>
</div>
<div v-if="connectCommands.length === 0" class="py-4 text-center text-neutral-500 text-sm">
No connect commands configured. Add a command below.
</div>
<div v-else class="space-y-2">
<div
v-for="(cmd, index) in connectCommands"
:key="index"
class="flex items-center gap-3 bg-neutral-800/50 rounded-lg p-3"
>
<input
:value="cmd"
@change="updateConnectCommand(index, ($event.target as HTMLInputElement).value)"
placeholder='e.g. say Welcome {player.name}!'
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
/>
<button
@click="removeConnectCommand(index)"
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
<button
@click="addConnectCommand"
class="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Plus class="w-4 h-4" />
Add Command
</button>
</div>
</div>
<!-- On Disconnect Tab -->
<div v-else-if="activeTab === 'disconnect'" class="space-y-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<div>
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">On Player Disconnect</h3>
<p class="text-xs text-neutral-500 mt-1">Commands executed when a player leaves the server</p>
</div>
<div v-if="disconnectCommands.length === 0" class="py-4 text-center text-neutral-500 text-sm">
No disconnect commands configured. Add a command below.
</div>
<div v-else class="space-y-2">
<div
v-for="(cmd, index) in disconnectCommands"
:key="index"
class="flex items-center gap-3 bg-neutral-800/50 rounded-lg p-3"
>
<input
:value="cmd"
@change="updateDisconnectCommand(index, ($event.target as HTMLInputElement).value)"
placeholder='e.g. say {player.name} has left'
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
/>
<button
@click="removeDisconnectCommand(index)"
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
<button
@click="addDisconnectCommand"
class="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
>
<Plus class="w-4 h-4" />
Add Command
</button>
</div>
</div>
</div>
<!-- Create Config Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New TimedExecute Config</h2>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="newConfigName"
placeholder="e.g. Default Timer Settings"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleCreateConfig"
/>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
<textarea
v-model="newConfigDesc"
rows="2"
placeholder="What is this config for?"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
/>
</div>
<div class="flex justify-end gap-2">
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="handleCreateConfig"
:disabled="!newConfigName.trim()"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
Create
</button>
</div>
</div>
</div>
</div>
<!-- Import from Server Modal -->
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
<p class="text-sm text-neutral-400 mb-4">
Import the current TimedExecute config from your live server. This will create a new config profile.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
<input
v-model="importConfigName"
placeholder="e.g. Imported Server Config"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
@keydown.enter="handleImport"
/>
</div>
<div class="flex justify-end gap-2">
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
<button
@click="handleImport"
:disabled="!importConfigName.trim()"
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
>
Import
</button>
</div>
</div>
</div>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff