10 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
77 changed files with 9033 additions and 47 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

@@ -37,6 +37,13 @@ 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';
@@ -109,6 +116,13 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
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('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

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

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

@@ -27,8 +27,6 @@ import {
AlertTriangle,
FileText,
FolderOpen,
Crosshair,
Navigation2,
Menu,
X,
} from 'lucide-vue-next'
@@ -39,27 +37,59 @@ const auth = useAuthStore()
const server = useServerStore()
const sidebarOpen = ref(false)
const navItems = [
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' },
{ name: 'Loot Builder', path: '/loot-builder', icon: Crosshair, permission: 'loot.view' },
{ name: 'Teleport Config', path: '/teleport-config', icon: Navigation2, permission: 'teleport.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: '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' },
],
},
{
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 = [
@@ -84,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>
@@ -150,8 +184,16 @@ function canShowNavItem(item: typeof navItems[0]): boolean {
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto py-2">
<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 navItems"
v-for="item in section.items"
v-show="canShowNavItem(item)"
:key="item.path"
:to="item.path"
@@ -164,15 +206,13 @@ function canShowNavItem(item: typeof navItems[0]): boolean {
<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" />
<div class="mt-4 mb-1 px-4">
<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>
<RouterLink
v-for="item in adminNavItems"

View File

@@ -110,6 +110,11 @@ 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',
@@ -120,6 +125,41 @@ const panelRoutes: RouteRecordRaw[] = [
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

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

@@ -546,3 +546,192 @@ export interface TeleportApplyResult {
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

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useTeleportStore } from '@/stores/teleport'
import { useToastStore } from '@/stores/toast'
import PermissionGroupEditor from '@/components/teleport/PermissionGroupEditor.vue'
import {
Save,
@@ -13,11 +12,9 @@ import {
Home,
Users,
Settings as SettingsIcon,
Loader2,
} from 'lucide-vue-next'
const store = useTeleportStore()
const toast = useToastStore()
const activeTab = ref<'general' | 'homes' | 'tpr' | 'vip'>('general')
const showCreateModal = ref(false)

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>