Compare commits
17 Commits
v1.0.8
...
redesign/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
769d75d937 | ||
|
|
f440fd7751 | ||
|
|
29615cb4f3 | ||
|
|
376ed9a98d | ||
|
|
b42a2d7ea7 | ||
|
|
560d023250 | ||
|
|
f91ef84832 | ||
|
|
ef128b47d2 | ||
|
|
1bb810f851 | ||
|
|
b4d1bc8dd0 | ||
|
|
d15ea28e8f | ||
|
|
7d5966839a | ||
|
|
2668014068 | ||
|
|
bb381569e3 | ||
|
|
39622de8dc | ||
|
|
500dca48a5 | ||
|
|
b542f30dcf |
12
CLAUDE.md
12
CLAUDE.md
@@ -423,3 +423,15 @@ 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.
|
||||
|
||||
22. **Build-green is not render-correct — visually verify UI work before calling it done.** The entire design-system re-skin (50+ files, six green commits) rendered almost completely unstyled in the browser — white background, no surfaces, no accent — because the design tokens never loaded. `vue-tsc -b` + `vite build` passed clean the whole time; CSS that *compiles* can still apply *zero* styles. One Playwright screenshot of the login exposed it in seconds. When the deliverable is visual, a green build is necessary but not sufficient: load it in a real browser (Playwright on the dev server at :5174), screenshot it, and assert on `getComputedStyle` — don't trust compilation alone. This is Lesson 17 with teeth.
|
||||
|
||||
23. **Tailwind v4 silently drops a nested `@import` barrel placed after `@import "tailwindcss"`.** `style.css` did `@import "tailwindcss"; @import "./styles/corrosion.css";` where corrosion.css was a barrel of eight `@import` token files. Once Tailwind v4 expands the tailwindcss import in place, the barrel's inner @imports no longer precede all statements, so PostCSS drops them — emitting only an easily-ignored "@import must precede all other statements" warning. Result: every design token resolved empty and the whole panel rendered unstyled. Import token/design CSS files **directly and contiguously** in the entry stylesheet; never via a nested barrel after the Tailwind import. The build warning you wave off as "pre-existing" may be the entire feature silently failing.
|
||||
|
||||
@@ -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)
|
||||
|
||||
33
backend-nest/src/entities/autodoors-config.entity.ts
Normal file
33
backend-nest/src/entities/autodoors-config.entity.ts
Normal 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;
|
||||
}
|
||||
33
backend-nest/src/entities/betterchat-config.entity.ts
Normal file
33
backend-nest/src/entities/betterchat-config.entity.ts
Normal 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;
|
||||
}
|
||||
33
backend-nest/src/entities/furnacesplitter-config.entity.ts
Normal file
33
backend-nest/src/entities/furnacesplitter-config.entity.ts
Normal 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;
|
||||
}
|
||||
33
backend-nest/src/entities/gather-config.entity.ts
Normal file
33
backend-nest/src/entities/gather-config.entity.ts
Normal 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;
|
||||
}
|
||||
33
backend-nest/src/entities/kits-config.entity.ts
Normal file
33
backend-nest/src/entities/kits-config.entity.ts
Normal 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;
|
||||
}
|
||||
33
backend-nest/src/entities/raidablebases-config.entity.ts
Normal file
33
backend-nest/src/entities/raidablebases-config.entity.ts
Normal 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;
|
||||
}
|
||||
33
backend-nest/src/entities/timedexecute-config.entity.ts
Normal file
33
backend-nest/src/entities/timedexecute-config.entity.ts
Normal 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;
|
||||
}
|
||||
80
backend-nest/src/modules/autodoors/autodoors.controller.ts
Normal file
80
backend-nest/src/modules/autodoors/autodoors.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/autodoors/autodoors.module.ts
Normal file
14
backend-nest/src/modules/autodoors/autodoors.module.ts
Normal 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 {}
|
||||
180
backend-nest/src/modules/autodoors/autodoors.service.ts
Normal file
180
backend-nest/src/modules/autodoors/autodoors.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
80
backend-nest/src/modules/betterchat/betterchat.controller.ts
Normal file
80
backend-nest/src/modules/betterchat/betterchat.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/betterchat/betterchat.module.ts
Normal file
14
backend-nest/src/modules/betterchat/betterchat.module.ts
Normal 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 {}
|
||||
180
backend-nest/src/modules/betterchat/betterchat.service.ts
Normal file
180
backend-nest/src/modules/betterchat/betterchat.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
80
backend-nest/src/modules/gather/gather.controller.ts
Normal file
80
backend-nest/src/modules/gather/gather.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/gather/gather.module.ts
Normal file
14
backend-nest/src/modules/gather/gather.module.ts
Normal 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 {}
|
||||
180
backend-nest/src/modules/gather/gather.service.ts
Normal file
180
backend-nest/src/modules/gather/gather.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
backend-nest/src/modules/kits/dto/create-kits-config.dto.ts
Normal file
19
backend-nest/src/modules/kits/dto/create-kits-config.dto.ts
Normal 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>;
|
||||
}
|
||||
14
backend-nest/src/modules/kits/dto/import-kits-config.dto.ts
Normal file
14
backend-nest/src/modules/kits/dto/import-kits-config.dto.ts
Normal 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;
|
||||
}
|
||||
25
backend-nest/src/modules/kits/dto/update-kits-config.dto.ts
Normal file
25
backend-nest/src/modules/kits/dto/update-kits-config.dto.ts
Normal 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;
|
||||
}
|
||||
80
backend-nest/src/modules/kits/kits.controller.ts
Normal file
80
backend-nest/src/modules/kits/kits.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/kits/kits.module.ts
Normal file
14
backend-nest/src/modules/kits/kits.module.ts
Normal 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 {}
|
||||
180
backend-nest/src/modules/kits/kits.service.ts
Normal file
180
backend-nest/src/modules/kits/kits.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
180
backend-nest/src/modules/raidablebases/raidablebases.service.ts
Normal file
180
backend-nest/src/modules/raidablebases/raidablebases.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/timedexecute/timedexecute.module.ts
Normal file
14
backend-nest/src/modules/timedexecute/timedexecute.module.ts
Normal 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 {}
|
||||
180
backend-nest/src/modules/timedexecute/timedexecute.service.ts
Normal file
180
backend-nest/src/modules/timedexecute/timedexecute.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
backend/migrations/015_gather_configs.sql
Normal file
11
backend/migrations/015_gather_configs.sql
Normal 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);
|
||||
11
backend/migrations/016_kits_configs.sql
Normal file
11
backend/migrations/016_kits_configs.sql
Normal 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);
|
||||
11
backend/migrations/017_betterchat_configs.sql
Normal file
11
backend/migrations/017_betterchat_configs.sql
Normal 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);
|
||||
11
backend/migrations/018_autodoors_configs.sql
Normal file
11
backend/migrations/018_autodoors_configs.sql
Normal 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);
|
||||
11
backend/migrations/019_furnacesplitter_configs.sql
Normal file
11
backend/migrations/019_furnacesplitter_configs.sql
Normal 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);
|
||||
11
backend/migrations/020_timedexecute_configs.sql
Normal file
11
backend/migrations/020_timedexecute_configs.sql
Normal 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);
|
||||
11
backend/migrations/021_raidablebases_configs.sql
Normal file
11
backend/migrations/021_raidablebases_configs.sql
Normal 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);
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en" class="dark" data-theme="dark" data-game="rust">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
@@ -9,8 +9,24 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<title>Corrosion Management</title>
|
||||
<script>
|
||||
/* FOUC guard — apply persisted theme/game to <html> before the app mounts,
|
||||
so the design-system tokens paint with the right skin from frame one. */
|
||||
(function () {
|
||||
try {
|
||||
var el = document.documentElement;
|
||||
var t = localStorage.getItem('cc-theme');
|
||||
var g = localStorage.getItem('cc-game');
|
||||
if (t === 'dark' || t === 'light') {
|
||||
el.setAttribute('data-theme', t);
|
||||
el.classList.toggle('dark', t === 'dark');
|
||||
}
|
||||
if (g) el.setAttribute('data-game', g);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-neutral-950">
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
8
frontend/src/assets/corrosion-mark.svg
Normal file
8
frontend/src/assets/corrosion-mark.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Corrosion">
|
||||
<path d="M40.2 9.45 A24 24 0 0 1 54.6 23.8" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||
<path d="M54.6 40.2 A24 24 0 0 1 40.2 54.6" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||
<path d="M23.8 54.6 A24 24 0 0 1 9.45 40.2" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||
<path d="M9.45 23.8 A24 24 0 0 1 23.8 9.45" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||
<path d="M32 16V24M32 40V48M16 32H24M40 32H48" stroke="currentColor" stroke-width="3.6" stroke-linecap="round"></path>
|
||||
<circle cx="32" cy="32" r="4.4" fill="currentColor"></circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 770 B |
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onErrorCaptured } from 'vue'
|
||||
import { AlertTriangle } from 'lucide-vue-next'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
|
||||
const hasError = ref(false)
|
||||
const errorMessage = ref('')
|
||||
@@ -20,18 +21,67 @@ function retry() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasError" class="min-h-screen bg-neutral-950 flex items-center justify-center p-6">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-8 max-w-md w-full text-center">
|
||||
<AlertTriangle class="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<h1 class="text-xl font-bold text-neutral-100 mb-2">Something went wrong</h1>
|
||||
<p class="text-sm text-neutral-400 mb-6">{{ errorMessage }}</p>
|
||||
<button
|
||||
@click="retry"
|
||||
class="px-4 py-2 bg-oxide-500 hover:bg-oxide-600 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<div v-if="hasError" class="eb-screen">
|
||||
<div class="eb-card">
|
||||
<div class="eb-icon-wrap">
|
||||
<Icon name="triangle-alert" :size="24" :stroke-width="1.75" />
|
||||
</div>
|
||||
<h1 class="eb-title">Something went wrong</h1>
|
||||
<p class="eb-msg">{{ errorMessage }}</p>
|
||||
<Button icon="refresh-cw" @click="retry">Retry</Button>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.eb-screen {
|
||||
min-height: 100vh;
|
||||
background: var(--surface-canvas);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.eb-card {
|
||||
background: var(--surface-base);
|
||||
box-shadow: var(--ring-default), var(--shadow-md);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-8);
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eb-icon-wrap {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--status-offline-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--status-offline-border);
|
||||
color: var(--status-offline);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.eb-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.eb-msg {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.55;
|
||||
max-width: 300px;
|
||||
}
|
||||
</style>
|
||||
|
||||
29
frontend/src/components/brand/CorrosionMark.vue
Normal file
29
frontend/src/components/brand/CorrosionMark.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Corrosion brand mark — segmented "C-core" reticle.
|
||||
* A bold ring split into four arc segments around a centered control node,
|
||||
* with N/E/S/W targeting ticks. Drawn in `currentColor` so it themes to the
|
||||
* active accent (set `color: var(--accent)` on a parent) and stays crisp to ~12px.
|
||||
* Source: design-system assets/mark.svg (64×64 viewBox).
|
||||
*/
|
||||
withDefaults(defineProps<{ size?: number | string }>(), { size: 24 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Corrosion"
|
||||
>
|
||||
<path d="M40.2 9.45 A24 24 0 0 1 54.6 23.8" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
|
||||
<path d="M54.6 40.2 A24 24 0 0 1 40.2 54.6" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
|
||||
<path d="M23.8 54.6 A24 24 0 0 1 9.45 40.2" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
|
||||
<path d="M9.45 23.8 A24 24 0 0 1 23.8 9.45" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
|
||||
<path d="M32 16V24M32 40V48M16 32H24M40 32H48" stroke="currentColor" stroke-width="3.6" stroke-linecap="round" />
|
||||
<circle cx="32" cy="32" r="4.4" fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
92
frontend/src/components/ds/brand/Logo.vue
Normal file
92
frontend/src/components/ds/brand/Logo.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Logo — Corrosion brand lockup.
|
||||
* Composes the CorrosionMark SVG + Oxanium wordmark + optional tagline.
|
||||
*
|
||||
* The mark renders in `currentColor`, so set `color: var(--accent)` on a
|
||||
* parent (or pass `markColor`) to theme it per active game.
|
||||
*
|
||||
* Props mirror Logo.jsx exactly:
|
||||
* size — base px size; drives mark em-size + wordmark scaling
|
||||
* wordmark — show the "Corrosion" text (default true)
|
||||
* tagline — false | true (→ "Management Panel") | custom string
|
||||
* glow — accent drop-shadow for marketing / login hero use
|
||||
* markColor — force a fixed color on the mark (bypasses currentColor theming)
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
wordmark?: boolean
|
||||
tagline?: boolean | string
|
||||
glow?: boolean
|
||||
markColor?: string
|
||||
}>(),
|
||||
{ size: 26, wordmark: true, tagline: false, glow: false },
|
||||
)
|
||||
|
||||
const gap = computed(() => Math.round(props.size * 0.4) + 'px')
|
||||
const wordmarkGap = computed(() => Math.round(props.size * 0.14) + 'px')
|
||||
const wordmarkFontSize = computed(() => (props.size * 0.62) + 'px')
|
||||
const taglineFontSize = computed(() => Math.max(8, props.size * 0.26) + 'px')
|
||||
const glowFilter = computed(() =>
|
||||
props.glow ? `drop-shadow(0 0 ${props.size * 0.5}px var(--accent-glow))` : 'none'
|
||||
)
|
||||
const tagText = computed(() =>
|
||||
typeof props.tagline === 'string' ? props.tagline : 'Management Panel'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="cc-logo"
|
||||
:style="{ display: 'inline-flex', alignItems: 'center', gap, lineHeight: 1 }"
|
||||
>
|
||||
<!-- Mark wrapper: sets font-size so CorrosionMark's 1em sizing works; applies glow -->
|
||||
<span
|
||||
:style="{
|
||||
fontSize: size + 'px',
|
||||
display: 'inline-flex',
|
||||
filter: glowFilter,
|
||||
color: markColor ?? undefined,
|
||||
}"
|
||||
>
|
||||
<CorrosionMark :size="size" />
|
||||
</span>
|
||||
|
||||
<!-- Wordmark + optional tagline -->
|
||||
<span
|
||||
v-if="wordmark"
|
||||
:style="{ display: 'inline-flex', flexDirection: 'column', gap: wordmarkGap }"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
fontFamily: 'var(--font-brand)',
|
||||
fontWeight: 800,
|
||||
fontSize: wordmarkFontSize,
|
||||
letterSpacing: '0.005em',
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1,
|
||||
}"
|
||||
>Corrosion</span>
|
||||
<span
|
||||
v-if="tagline"
|
||||
:style="{
|
||||
fontFamily: 'var(--font-brand)',
|
||||
fontWeight: 600,
|
||||
fontSize: taglineFontSize,
|
||||
letterSpacing: '0.26em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--accent-text)',
|
||||
lineHeight: 1,
|
||||
}"
|
||||
>{{ tagText }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-logo { user-select: none; }
|
||||
</style>
|
||||
62
frontend/src/components/ds/core/Badge.vue
Normal file
62
frontend/src/components/ds/core/Badge.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
/** Badge — compact status/label chip. Tone drives fg/soft-bg/border; `solid` fills. */
|
||||
import { computed } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import StatusDot from './StatusDot.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tone?: 'neutral' | 'accent' | 'online' | 'offline' | 'warn' | 'info' | 'starting' | 'wiping'
|
||||
solid?: boolean
|
||||
dot?: boolean
|
||||
pulse?: boolean
|
||||
icon?: string
|
||||
size?: 'md' | 'lg'
|
||||
mono?: boolean
|
||||
uppercase?: boolean
|
||||
}>(),
|
||||
{ tone: 'neutral', solid: false, dot: false, pulse: false, size: 'md', mono: false, uppercase: false },
|
||||
)
|
||||
|
||||
const NEUTRAL: [string, string, string] = ['var(--text-secondary)', 'var(--surface-raised-2)', 'var(--border-default)']
|
||||
const TONES: Record<string, [string, string, string]> = {
|
||||
neutral: NEUTRAL,
|
||||
accent: ['var(--accent-text)', 'var(--accent-soft)', 'var(--accent-border)'],
|
||||
online: ['var(--status-online)', 'var(--status-online-soft)', 'var(--status-online-border)'],
|
||||
offline: ['var(--status-offline)', 'var(--status-offline-soft)', 'var(--status-offline-border)'],
|
||||
warn: ['var(--status-warn)', 'var(--status-warn-soft)', 'var(--status-warn-border)'],
|
||||
info: ['var(--status-info)', 'var(--status-info-soft)', 'var(--status-info-border)'],
|
||||
starting: ['var(--status-starting)', 'var(--status-starting-soft)', 'var(--status-starting-border)'],
|
||||
wiping: ['var(--status-wiping)', 'var(--status-wiping-soft)', 'var(--status-wiping-border)'],
|
||||
}
|
||||
|
||||
const styleObj = computed(() => {
|
||||
const [fg, soft, border] = TONES[props.tone] ?? NEUTRAL
|
||||
return props.solid
|
||||
? { background: fg, color: 'var(--surface-canvas)', boxShadow: 'none' }
|
||||
: { background: soft, color: fg, boxShadow: `inset 0 0 0 1px ${border}` }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="cc-badge"
|
||||
:class="[size === 'lg' && 'cc-badge--lg', mono && 'cc-badge--mono', uppercase && 'cc-badge--uppercase']"
|
||||
:style="styleObj"
|
||||
>
|
||||
<StatusDot v-if="dot" :tone="tone" :size="6" :pulse="pulse" />
|
||||
<Icon v-if="icon" :name="icon" :size="size === 'lg' ? 13 : 12" :stroke-width="2.5" />
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-badge {
|
||||
display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 0 8px;
|
||||
font-family: var(--font-sans); font-weight: 600; font-size: var(--text-xs); line-height: 1;
|
||||
border-radius: var(--radius-sm); white-space: nowrap; letter-spacing: 0.005em;
|
||||
}
|
||||
.cc-badge--lg { height: 24px; padding: 0 10px; font-size: var(--text-sm); }
|
||||
.cc-badge--mono { font-family: var(--font-mono); font-weight: 500; letter-spacing: 0; font-variant-numeric: tabular-nums; }
|
||||
.cc-badge--uppercase { text-transform: uppercase; letter-spacing: var(--tracking-wider); font-size: var(--text-2xs); }
|
||||
</style>
|
||||
82
frontend/src/components/ds/core/Button.vue
Normal file
82
frontend/src/components/ds/core/Button.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Button — primary action control; `variant="primary"` carries the live game accent.
|
||||
* Variants: primary | secondary | ghost | outline | danger | danger-soft.
|
||||
* Sizes: sm | md | lg. Pass Lucide names via `icon` / `iconRight`.
|
||||
* Native click bubbles via attribute fall-through (root is the <button>).
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'outline' | 'danger' | 'danger-soft'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
icon?: string
|
||||
iconRight?: string
|
||||
loading?: boolean
|
||||
block?: boolean
|
||||
disabled?: boolean
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}>(),
|
||||
{ variant: 'primary', size: 'md', loading: false, block: false, disabled: false, type: 'button' },
|
||||
)
|
||||
|
||||
const iconSize = computed(() => (props.size === 'lg' ? 17 : props.size === 'sm' ? 14 : 15))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
:class="[
|
||||
'cc-btn',
|
||||
`cc-btn--${variant}`,
|
||||
size !== 'md' && `cc-btn--${size}`,
|
||||
block && 'cc-btn--block',
|
||||
]"
|
||||
>
|
||||
<span v-if="loading" class="cc-btn__spin" />
|
||||
<Icon v-else-if="icon" :name="icon" :size="iconSize" :stroke-width="2.25" />
|
||||
<span v-if="$slots.default"><slot /></span>
|
||||
<Icon v-if="iconRight && !loading" :name="iconRight" :size="iconSize" :stroke-width="2.25" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||
font-family: var(--font-sans); font-weight: 600; font-size: var(--text-sm); line-height: 1;
|
||||
white-space: nowrap; height: var(--control-h-md); padding: 0 14px;
|
||||
border-radius: var(--radius-md); border: 1px solid transparent; cursor: pointer; user-select: none;
|
||||
transition: var(--transition-colors), transform var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.cc-btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.cc-btn:active { transform: translateY(0.5px); }
|
||||
.cc-btn[disabled], .cc-btn[aria-disabled="true"] { opacity: 0.45; pointer-events: none; }
|
||||
.cc-btn--block { width: 100%; }
|
||||
.cc-btn--sm { height: var(--control-h-sm); padding: 0 10px; font-size: var(--text-xs); border-radius: var(--radius-sm); gap: 6px; }
|
||||
.cc-btn--lg { height: var(--control-h-lg); padding: 0 18px; font-size: var(--text-base); gap: 9px; }
|
||||
|
||||
.cc-btn--primary { background: var(--accent); color: var(--accent-contrast); }
|
||||
.cc-btn--primary:hover { background: var(--accent-hover); }
|
||||
.cc-btn--primary:active { background: var(--accent-press); }
|
||||
|
||||
.cc-btn--secondary { background: var(--surface-raised-2); color: var(--text-primary); box-shadow: var(--ring-default); }
|
||||
.cc-btn--secondary:hover { background: var(--surface-active); }
|
||||
|
||||
.cc-btn--ghost { background: transparent; color: var(--text-secondary); }
|
||||
.cc-btn--ghost:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
|
||||
.cc-btn--outline { background: transparent; color: var(--accent-text); box-shadow: inset 0 0 0 1px var(--accent-border); }
|
||||
.cc-btn--outline:hover { background: var(--accent-soft); }
|
||||
|
||||
.cc-btn--danger { background: var(--danger); color: #fff; }
|
||||
.cc-btn--danger:hover { filter: brightness(1.1); }
|
||||
|
||||
.cc-btn--danger-soft { background: var(--status-offline-soft); color: var(--danger); box-shadow: inset 0 0 0 1px var(--status-offline-border); }
|
||||
.cc-btn--danger-soft:hover { background: var(--danger); color: #fff; }
|
||||
|
||||
.cc-btn__spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid currentColor; border-top-color: transparent; animation: cc-btn-spin 0.6s linear infinite; }
|
||||
@keyframes cc-btn-spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
81
frontend/src/components/ds/core/Icon.vue
Normal file
81
frontend/src/components/ds/core/Icon.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Icon — renders a Lucide icon by kebab-case name (matches the design system's
|
||||
* string `icon` prop API, e.g. <Icon name="refresh-cw" />). Maps to
|
||||
* `lucide-vue-next` via a registry so the bundle only ships icons we use.
|
||||
* Lucide icons render with `currentColor`, so they theme to the parent's color.
|
||||
* Add new icons to `registry` as the port grows.
|
||||
*/
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Play, Pause, RefreshCw, Trash2, Settings, Terminal, Power, Box, Sun, Moon,
|
||||
Loader, LoaderCircle, TrendingUp, TrendingDown, Minus, Plus, Server, Users,
|
||||
Puzzle, FolderOpen, Cpu, BarChart3, Rocket, TriangleAlert, Bell, Search,
|
||||
ChevronDown, ChevronRight, ChevronLeft, ChevronUp, Check, X, Calendar, Clock,
|
||||
ShoppingCart, CreditCard, HardDrive, Activity, Shield, Download, Upload,
|
||||
Wifi, WifiOff, Map, Gauge, Gift, Flame, DoorOpen, Pickaxe, Swords, Crosshair,
|
||||
Navigation, MessageSquare, FileText, Bookmark, ExternalLink, Copy, LogOut,
|
||||
Eye, EyeOff, Globe, Key, Layers, List, MoreVertical, Zap,
|
||||
Info, OctagonAlert, CircleCheck, Sparkles, Inbox,
|
||||
LayoutDashboard, CalendarClock, Drama, ChevronsUpDown, ServerCog,
|
||||
LayoutGrid, SquareDashed, MemoryStick, CornerDownLeft,
|
||||
Ban, Flag,
|
||||
CircleAlert, ArrowDown, Award, DollarSign, FlaskConical, Mail, Package,
|
||||
Pencil, Save, ShoppingBag, Target, User,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ name: string; size?: number; strokeWidth?: number }>(),
|
||||
{ size: 16, strokeWidth: 2 },
|
||||
)
|
||||
|
||||
const registry: Record<string, Component> = {
|
||||
play: Play, pause: Pause, 'refresh-cw': RefreshCw, 'trash-2': Trash2,
|
||||
settings: Settings, terminal: Terminal, power: Power, box: Box, sun: Sun,
|
||||
moon: Moon, loader: LoaderCircle, 'loader-2': Loader, 'trending-up': TrendingUp,
|
||||
'trending-down': TrendingDown, minus: Minus, plus: Plus, server: Server,
|
||||
users: Users, puzzle: Puzzle, 'folder-open': FolderOpen, cpu: Cpu,
|
||||
'bar-chart-3': BarChart3, rocket: Rocket, 'triangle-alert': TriangleAlert,
|
||||
bell: Bell, search: Search, 'chevron-down': ChevronDown,
|
||||
'chevron-right': ChevronRight, 'chevron-left': ChevronLeft, 'chevron-up': ChevronUp,
|
||||
check: Check, x: X, calendar: Calendar, clock: Clock,
|
||||
'shopping-cart': ShoppingCart, 'credit-card': CreditCard, 'hard-drive': HardDrive,
|
||||
activity: Activity, shield: Shield, download: Download, upload: Upload,
|
||||
wifi: Wifi, 'wifi-off': WifiOff, map: Map, gauge: Gauge, gift: Gift,
|
||||
flame: Flame, 'door-open': DoorOpen, pickaxe: Pickaxe, swords: Swords,
|
||||
crosshair: Crosshair, navigation: Navigation, 'message-square': MessageSquare,
|
||||
'file-text': FileText, bookmark: Bookmark, 'external-link': ExternalLink,
|
||||
copy: Copy, 'log-out': LogOut, eye: Eye, 'eye-off': EyeOff, globe: Globe,
|
||||
key: Key, layers: Layers, list: List, 'more-vertical': MoreVertical, zap: Zap,
|
||||
info: Info, 'octagon-alert': OctagonAlert, 'circle-check': CircleCheck,
|
||||
sparkles: Sparkles, inbox: Inbox,
|
||||
'layout-dashboard': LayoutDashboard, 'calendar-clock': CalendarClock, drama: Drama,
|
||||
'chevrons-up-down': ChevronsUpDown, 'server-cog': ServerCog, 'layout-grid': LayoutGrid,
|
||||
'square-dashed': SquareDashed, 'memory-stick': MemoryStick, 'corner-down-left': CornerDownLeft,
|
||||
ban: Ban, flag: Flag,
|
||||
'alert-circle': CircleAlert, 'arrow-down': ArrowDown, award: Award,
|
||||
'dollar-sign': DollarSign, 'flask-conical': FlaskConical, mail: Mail,
|
||||
package: Package, pencil: Pencil, save: Save, 'shopping-bag': ShoppingBag,
|
||||
target: Target, user: User,
|
||||
}
|
||||
|
||||
const cmp = computed<Component | null>(() => registry[props.name] ?? null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="cmp"
|
||||
v-if="cmp"
|
||||
class="cc-icon"
|
||||
:size="size"
|
||||
:width="size"
|
||||
:height="size"
|
||||
:stroke-width="strokeWidth"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-icon { display: inline-block; flex: none; vertical-align: middle; }
|
||||
</style>
|
||||
62
frontend/src/components/ds/core/IconButton.vue
Normal file
62
frontend/src/components/ds/core/IconButton.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* IconButton — square icon-only action button.
|
||||
* Variants: ghost | solid | accent | danger.
|
||||
* Sizes: sm | md | lg.
|
||||
* Native click bubbles via attribute fall-through (root is <button>).
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon: string
|
||||
variant?: 'ghost' | 'solid' | 'accent' | 'danger'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
active?: boolean
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ variant: 'ghost', size: 'md', active: false, disabled: false },
|
||||
)
|
||||
|
||||
const iconPx = computed(() => (props.size === 'lg' ? 19 : props.size === 'sm' ? 15 : 17))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="label"
|
||||
:title="label"
|
||||
:disabled="disabled"
|
||||
:class="[
|
||||
'cc-iconbtn',
|
||||
variant !== 'ghost' && `cc-iconbtn--${variant}`,
|
||||
size !== 'md' && `cc-iconbtn--${size}`,
|
||||
active && 'cc-iconbtn--active',
|
||||
]"
|
||||
>
|
||||
<Icon :name="icon" :size="iconPx" :stroke-width="2" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-iconbtn {
|
||||
display:inline-flex; align-items:center; justify-content:center; flex:none;
|
||||
width:var(--control-h-md); height:var(--control-h-md); border-radius:var(--radius-md);
|
||||
border:1px solid transparent; background:transparent; color:var(--text-secondary);
|
||||
cursor:pointer; transition:var(--transition-colors), transform var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.cc-iconbtn:hover { background:var(--surface-hover); color:var(--text-primary); }
|
||||
.cc-iconbtn:active { transform: translateY(0.5px); }
|
||||
.cc-iconbtn:focus-visible { outline:none; box-shadow:var(--focus-ring); }
|
||||
.cc-iconbtn[disabled] { opacity:.4; pointer-events:none; }
|
||||
.cc-iconbtn--sm { width:var(--control-h-sm); height:var(--control-h-sm); border-radius:var(--radius-sm); }
|
||||
.cc-iconbtn--lg { width:var(--control-h-lg); height:var(--control-h-lg); }
|
||||
.cc-iconbtn--solid { background:var(--surface-raised-2); box-shadow:var(--ring-default); }
|
||||
.cc-iconbtn--solid:hover { background:var(--surface-active); }
|
||||
.cc-iconbtn--accent { background:var(--accent); color:var(--accent-contrast); }
|
||||
.cc-iconbtn--accent:hover { background:var(--accent-hover); }
|
||||
.cc-iconbtn--danger:hover { background:var(--status-offline-soft); color:var(--danger); }
|
||||
.cc-iconbtn--active { background:var(--accent-soft); color:var(--accent-text); box-shadow: inset 0 0 0 1px var(--accent-border); }
|
||||
</style>
|
||||
20
frontend/src/components/ds/core/Kbd.vue
Normal file
20
frontend/src/components/ds/core/Kbd.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Kbd — keyboard shortcut key chip, rendered as <kbd>.
|
||||
* Uses mono font and inset border + bottom shadow to mimic a physical key.
|
||||
* No props — purely a presentational slot wrapper; native attrs fall through.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<kbd class="cc-kbd"><slot /></kbd>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-kbd {
|
||||
display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 5px;
|
||||
font-family:var(--font-mono); font-size:11px; font-weight:500; line-height:1; color:var(--text-secondary);
|
||||
background:var(--surface-raised-2); border-radius:var(--radius-sm);
|
||||
box-shadow: inset 0 0 0 1px var(--border-default), 0 1px 0 var(--border-default);
|
||||
}
|
||||
</style>
|
||||
48
frontend/src/components/ds/core/StatusDot.vue
Normal file
48
frontend/src/components/ds/core/StatusDot.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
/** StatusDot — small live-status dot; pulses when live. Tone maps to status tokens. */
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
tone?: 'online' | 'offline' | 'warn' | 'info' | 'starting' | 'wiping' | 'neutral' | 'accent'
|
||||
size?: number
|
||||
pulse?: boolean
|
||||
}>(),
|
||||
{ tone: 'online', size: 8, pulse: false },
|
||||
)
|
||||
|
||||
const TONE: Record<string, string> = {
|
||||
online: 'var(--status-online)',
|
||||
offline: 'var(--status-offline)',
|
||||
warn: 'var(--status-warn)',
|
||||
info: 'var(--status-info)',
|
||||
starting: 'var(--status-starting)',
|
||||
wiping: 'var(--status-wiping)',
|
||||
neutral: 'var(--text-muted)',
|
||||
accent: 'var(--accent)',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="cc-dot"
|
||||
:class="pulse && 'cc-dot--pulse'"
|
||||
:style="{
|
||||
width: size + 'px',
|
||||
height: size + 'px',
|
||||
background: TONE[tone] || TONE.neutral,
|
||||
boxShadow: pulse ? '0 0 8px -1px ' + (TONE[tone] || TONE.neutral) : 'none',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-dot { display: inline-block; flex: none; border-radius: 50%; position: relative; }
|
||||
.cc-dot--pulse::after {
|
||||
content: ''; position: absolute; inset: 0; border-radius: 50%; background: inherit;
|
||||
animation: cc-dot-pulse 1.8s var(--ease-out) infinite;
|
||||
}
|
||||
@keyframes cc-dot-pulse {
|
||||
0% { transform: scale(1); opacity: 0.6; }
|
||||
70%, 100% { transform: scale(2.6); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .cc-dot--pulse::after { animation: none; } }
|
||||
</style>
|
||||
52
frontend/src/components/ds/core/Tag.vue
Normal file
52
frontend/src/components/ds/core/Tag.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Tag — removable or static label chip.
|
||||
* Set `removable` to show the dismiss ×; emit `remove` is fired when clicked.
|
||||
* Optional `icon` prefix via Icon registry.
|
||||
*/
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
icon?: string
|
||||
removable?: boolean
|
||||
}>(),
|
||||
{ removable: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="['cc-tag', !removable && 'cc-tag--static']">
|
||||
<Icon v-if="icon" :name="icon" :size="12" :stroke-width="2.25" />
|
||||
<span><slot /></span>
|
||||
<button
|
||||
v-if="removable"
|
||||
type="button"
|
||||
class="cc-tag__x"
|
||||
aria-label="Remove"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<Icon name="x" :size="11" :stroke-width="2.5" />
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-tag {
|
||||
display:inline-flex; align-items:center; gap:6px; height:24px; padding:0 4px 0 9px;
|
||||
font-family:var(--font-sans); font-size:var(--text-xs); font-weight:500; line-height:1;
|
||||
color:var(--text-secondary); background:var(--surface-raised-2);
|
||||
border-radius:var(--radius-sm); box-shadow:var(--ring-default);
|
||||
}
|
||||
.cc-tag--static { padding:0 9px; }
|
||||
.cc-tag__x {
|
||||
display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px;
|
||||
border-radius:var(--radius-xs); color:var(--text-tertiary); cursor:pointer; border:0; background:transparent;
|
||||
transition:var(--transition-colors);
|
||||
}
|
||||
.cc-tag__x:hover { background:var(--surface-active); color:var(--text-primary); }
|
||||
</style>
|
||||
72
frontend/src/components/ds/data/Avatar.vue
Normal file
72
frontend/src/components/ds/data/Avatar.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Avatar — player / operator avatar. Renders an image, or falls back to initials.
|
||||
* Optional status dot (online / offline / warn / idle) sits bottom-right.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name?: string
|
||||
src?: string
|
||||
size?: number
|
||||
shape?: 'rounded' | 'circle'
|
||||
status?: 'online' | 'offline' | 'warn' | 'idle'
|
||||
}>(),
|
||||
{ name: '', size: 32, shape: 'rounded' },
|
||||
)
|
||||
|
||||
const TONE: Record<string, string> = {
|
||||
online: 'var(--status-online)',
|
||||
offline: 'var(--status-offline)',
|
||||
warn: 'var(--status-warn)',
|
||||
idle: 'var(--text-muted)',
|
||||
}
|
||||
|
||||
const initials = computed(() =>
|
||||
props.name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map(w => w[0] ?? '')
|
||||
.join('')
|
||||
.toUpperCase() || '?',
|
||||
)
|
||||
|
||||
const dotSize = computed(() => Math.max(7, Math.round(props.size * 0.28)))
|
||||
const dotColor = computed(() => (props.status ? (TONE[props.status] ?? TONE.idle) : ''))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="cc-avatar"
|
||||
:class="shape === 'circle' && 'cc-avatar--circle'"
|
||||
:style="{
|
||||
width: size + 'px',
|
||||
height: size + 'px',
|
||||
fontSize: Math.round(size * 0.4) + 'px',
|
||||
}"
|
||||
>
|
||||
<img v-if="src" :src="src" :alt="name" />
|
||||
<template v-else>{{ initials }}</template>
|
||||
<span
|
||||
v-if="status"
|
||||
class="cc-avatar__status"
|
||||
:style="{ width: dotSize + 'px', height: dotSize + 'px', background: dotColor }"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-avatar {
|
||||
position: relative; display: inline-flex; align-items: center; justify-content: center; flex: none;
|
||||
border-radius: var(--radius-md); background: var(--surface-active); color: var(--text-secondary);
|
||||
font-family: var(--font-mono); font-weight: 600; overflow: visible; box-shadow: var(--ring-default);
|
||||
}
|
||||
.cc-avatar--circle { border-radius: 50%; }
|
||||
.cc-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
||||
.cc-avatar__status {
|
||||
position: absolute; right: -2px; bottom: -2px; border-radius: 50%;
|
||||
box-shadow: 0 0 0 2px var(--surface-base);
|
||||
}
|
||||
</style>
|
||||
54
frontend/src/components/ds/data/ConsoleLine.vue
Normal file
54
frontend/src/components/ds/data/ConsoleLine.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ConsoleLine — one line in the RCON / server log stream.
|
||||
* Monospace, color-coded by level. Optional timestamp and actor (who).
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
time?: string
|
||||
level?: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
|
||||
who?: string
|
||||
}>(),
|
||||
{ level: 'info' },
|
||||
)
|
||||
|
||||
const LABEL: Record<string, string> = {
|
||||
cmd: 'cmd',
|
||||
chat: 'chat',
|
||||
info: 'info',
|
||||
warn: 'warn',
|
||||
error: 'err',
|
||||
connect: 'join',
|
||||
kill: 'kill',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['cc-line', 'cc-line--' + level]">
|
||||
<span v-if="time" class="cc-line__time">{{ time }}</span>
|
||||
<span class="cc-line__tag">{{ LABEL[level ?? 'info'] ?? level }}</span>
|
||||
<span class="cc-line__msg">
|
||||
<span v-if="who" class="cc-line__who">{{ who }} </span>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-line { display: flex; gap: 10px; padding: 2px 12px; font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.65; align-items: baseline; }
|
||||
.cc-line:hover { background: var(--surface-hover); }
|
||||
.cc-line__time { color: var(--text-muted); flex: none; font-variant-numeric: tabular-nums; }
|
||||
.cc-line__tag { flex: none; text-transform: uppercase; font-weight: 600; font-size: 10px; letter-spacing: .05em; padding: 0 5px; border-radius: var(--radius-xs); height: 15px; display: inline-flex; align-items: center; }
|
||||
.cc-line__msg { color: var(--text-secondary); white-space: pre-wrap; word-break: break-word; min-width: 0; }
|
||||
.cc-line__who { color: var(--accent-text); }
|
||||
.cc-line--cmd .cc-line__tag { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.cc-line--cmd .cc-line__msg { color: var(--text-primary); }
|
||||
.cc-line--chat .cc-line__tag { background: var(--surface-active); color: var(--text-secondary); }
|
||||
.cc-line--info .cc-line__tag { background: var(--status-info-soft); color: var(--status-info); }
|
||||
.cc-line--warn .cc-line__tag { background: var(--status-warn-soft); color: var(--status-warn); }
|
||||
.cc-line--warn .cc-line__msg { color: var(--status-warn); }
|
||||
.cc-line--error .cc-line__tag { background: var(--status-offline-soft); color: var(--status-offline); }
|
||||
.cc-line--error .cc-line__msg { color: var(--status-offline); }
|
||||
.cc-line--connect .cc-line__tag { background: var(--status-online-soft); color: var(--status-online); }
|
||||
.cc-line--kill .cc-line__tag { background: var(--status-wiping-soft); color: var(--status-wiping); }
|
||||
</style>
|
||||
57
frontend/src/components/ds/data/Panel.vue
Normal file
57
frontend/src/components/ds/data/Panel.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Panel — standard section container.
|
||||
* Header: optional eyebrow / title / subtitle / right-aligned actions.
|
||||
* Body: padding removed when flushBody=true (tables / lists manage their own).
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
subtitle?: string
|
||||
eyebrow?: string
|
||||
variant?: 'base' | 'raised' | 'flush'
|
||||
flushBody?: boolean
|
||||
}>(),
|
||||
{ variant: 'base', flushBody: false },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="[
|
||||
'cc-panel',
|
||||
variant === 'raised' && 'cc-panel--raised',
|
||||
variant === 'flush' && 'cc-panel--flush',
|
||||
]"
|
||||
>
|
||||
<header v-if="title || subtitle || eyebrow || $slots.actions" class="cc-panel__head">
|
||||
<div class="cc-panel__titles">
|
||||
<div v-if="eyebrow" class="t-eyebrow">{{ eyebrow }}</div>
|
||||
<div v-if="title" class="cc-panel__title">
|
||||
{{ title }}
|
||||
<slot name="title-append" />
|
||||
</div>
|
||||
<div v-if="subtitle" class="cc-panel__sub">{{ subtitle }}</div>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="cc-panel__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
<div :class="['cc-panel__body', flushBody && 'cc-panel__body--flush']">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-panel { background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default); display: flex; flex-direction: column; min-width: 0; }
|
||||
.cc-panel--raised { background: var(--surface-raised); }
|
||||
.cc-panel--flush { box-shadow: none; background: transparent; }
|
||||
.cc-panel__head { display: flex; align-items: center; gap: 12px; padding: 13px 16px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.cc-panel__titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||
.cc-panel__title { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 8px; }
|
||||
.cc-panel__sub { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.cc-panel__actions { display: flex; align-items: center; gap: 6px; flex: none; }
|
||||
.cc-panel__body { padding: 16px; min-width: 0; }
|
||||
.cc-panel__body--flush { padding: 0; }
|
||||
</style>
|
||||
98
frontend/src/components/ds/data/PlayersChart.vue
Normal file
98
frontend/src/components/ds/data/PlayersChart.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* PlayersChart — themed ECharts area chart of players online.
|
||||
* Reads the live design tokens (--accent etc.) from CSS so it matches the
|
||||
* active theme/game, and re-renders when data-game / data-theme flip on <html>.
|
||||
*/
|
||||
import { onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ height?: number; data?: number[]; max?: number }>(),
|
||||
{ height: 200, max: 200 },
|
||||
)
|
||||
|
||||
const el = useTemplateRef<HTMLDivElement>('el')
|
||||
let chart: echarts.ECharts | null = null
|
||||
let ro: ResizeObserver | null = null
|
||||
let mo: MutationObserver | null = null
|
||||
|
||||
const DEFAULT_SERIES = [
|
||||
60, 52, 44, 38, 33, 30, 34, 46, 62, 78, 92, 104,
|
||||
118, 126, 131, 138, 142, 151, 168, 182, 176, 150, 112, 84,
|
||||
]
|
||||
|
||||
function cssVar(name: string, node?: HTMLElement): string {
|
||||
return getComputedStyle(node || document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
if (!chart || !el.value) return
|
||||
const node = el.value
|
||||
const accent = cssVar('--accent', node) || '#f26622'
|
||||
const grid = cssVar('--border-subtle', node) || 'rgba(255,255,255,0.06)'
|
||||
const text = cssVar('--text-tertiary', node) || '#767d89'
|
||||
const mono = 'JetBrains Mono, monospace'
|
||||
const hours = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)
|
||||
const series = props.data ?? DEFAULT_SERIES
|
||||
|
||||
chart.setOption({
|
||||
animationDuration: 700,
|
||||
grid: { left: 8, right: 12, top: 14, bottom: 22, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: cssVar('--surface-overlay', node) || '#1f2329',
|
||||
borderColor: cssVar('--border-default', node) || 'rgba(255,255,255,0.1)',
|
||||
borderWidth: 1,
|
||||
textStyle: { color: cssVar('--text-primary', node) || '#fff', fontFamily: mono, fontSize: 11 },
|
||||
axisPointer: { type: 'line', lineStyle: { color: accent, opacity: 0.5 } },
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category', data: hours, boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: grid } }, axisTick: { show: false },
|
||||
axisLabel: { color: text, fontFamily: mono, fontSize: 10, interval: 3 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value', max: props.max,
|
||||
splitLine: { lineStyle: { color: grid } },
|
||||
axisLabel: { color: text, fontFamily: mono, fontSize: 10 },
|
||||
},
|
||||
series: [{
|
||||
type: 'line', smooth: 0.4, symbol: 'none', data: series,
|
||||
lineStyle: { color: accent, width: 2 },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: accent + '55' },
|
||||
{ offset: 1, color: accent + '00' },
|
||||
]),
|
||||
},
|
||||
markLine: {
|
||||
silent: true, symbol: 'none',
|
||||
lineStyle: { color: text, type: 'dashed', opacity: 0.5 },
|
||||
data: [{ yAxis: props.max, label: { formatter: `cap ${props.max}`, color: text, fontFamily: mono, fontSize: 9 } }],
|
||||
},
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!el.value) return
|
||||
chart = echarts.init(el.value, undefined, { renderer: 'canvas' })
|
||||
render()
|
||||
ro = new ResizeObserver(() => chart?.resize())
|
||||
ro.observe(el.value)
|
||||
mo = new MutationObserver(render)
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-game', 'data-theme'] })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ro?.disconnect()
|
||||
mo?.disconnect()
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" :style="{ width: '100%', height: height + 'px' }" />
|
||||
</template>
|
||||
53
frontend/src/components/ds/data/ResourceMeter.vue
Normal file
53
frontend/src/components/ds/data/ResourceMeter.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ResourceMeter — labeled utilization bar (CPU, RAM, disk, network).
|
||||
* tone="auto" colors by threshold: green <70%, amber <90%, red ≥90%.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
icon?: string
|
||||
value?: number
|
||||
sub?: string
|
||||
tone?: 'auto' | 'ok' | 'warn' | 'danger' | 'accent'
|
||||
}>(),
|
||||
{ value: 0, tone: 'auto' },
|
||||
)
|
||||
|
||||
const pct = computed(() => Math.max(0, Math.min(100, props.value)))
|
||||
|
||||
const resolvedTone = computed(() => {
|
||||
if (props.tone !== 'auto') return props.tone
|
||||
return pct.value >= 90 ? 'danger' : pct.value >= 70 ? 'warn' : 'ok'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['cc-meter', 'cc-meter--' + resolvedTone]">
|
||||
<div class="cc-meter__top">
|
||||
<span class="cc-meter__label">{{ label }}</span>
|
||||
<span class="cc-meter__val">
|
||||
{{ pct }}%<span v-if="sub" class="cc-meter__sub">{{ sub }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="cc-meter__track">
|
||||
<div class="cc-meter__fill" :style="{ width: pct + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-meter { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.cc-meter__top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.cc-meter__label { font-size: var(--text-xs); color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
|
||||
.cc-meter__val { font-family: var(--font-mono); font-size: var(--text-xs); font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||
.cc-meter__sub { color: var(--text-muted); font-weight: 400; margin-left: 4px; }
|
||||
.cc-meter__track { height: 6px; border-radius: var(--radius-pill); background: var(--surface-active); overflow: hidden; }
|
||||
.cc-meter__fill { height: 100%; border-radius: var(--radius-pill); transition: width var(--dur-slow) var(--ease-out), background var(--dur-base); }
|
||||
.cc-meter--accent .cc-meter__fill { background: var(--accent); }
|
||||
.cc-meter--ok .cc-meter__fill { background: var(--status-online); }
|
||||
.cc-meter--warn .cc-meter__fill { background: var(--status-warn); }
|
||||
.cc-meter--danger .cc-meter__fill { background: var(--status-offline); }
|
||||
</style>
|
||||
166
frontend/src/components/ds/data/ServerCard.vue
Normal file
166
frontend/src/components/ds/data/ServerCard.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ServerCard — server instance summary card.
|
||||
* Sets :data-game so per-game accent token re-skins apply via the global [data-game] selector.
|
||||
* Status drives the dot + left rail color.
|
||||
* `offline` dims the card and swaps the power IconButton to a Start action.
|
||||
* Pending state shows when status==='online' && cpu==null && ram==null.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import ResourceMeter from './ResourceMeter.vue'
|
||||
|
||||
export interface StatItem {
|
||||
label: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
game?: string
|
||||
gameIcon?: string
|
||||
name: string
|
||||
region?: string
|
||||
map?: string
|
||||
version?: string
|
||||
status?: 'online' | 'offline' | 'starting' | 'wiping' | 'updating'
|
||||
players?: { cur: number; max: number }
|
||||
cpu?: number
|
||||
ram?: number
|
||||
ramSub?: string
|
||||
ip?: string
|
||||
stats?: StatItem[]
|
||||
}>(),
|
||||
{
|
||||
game: 'rust',
|
||||
gameIcon: 'box',
|
||||
status: 'online',
|
||||
players: () => ({ cur: 0, max: 0 }),
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
console: []
|
||||
settings: []
|
||||
power: []
|
||||
}>()
|
||||
|
||||
interface StatusEntry {
|
||||
tone: 'online' | 'offline' | 'warn' | 'info' | 'starting' | 'wiping' | 'neutral' | 'accent'
|
||||
label: string
|
||||
pulse: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_STATUS: StatusEntry = { tone: 'online', label: 'Online', pulse: true }
|
||||
|
||||
const STATUS_MAP: Record<string, StatusEntry> = {
|
||||
online: { tone: 'online', label: 'Online', pulse: true },
|
||||
offline: { tone: 'offline', label: 'Offline', pulse: false },
|
||||
starting: { tone: 'starting', label: 'Booting', pulse: true },
|
||||
wiping: { tone: 'wiping', label: 'Wiping', pulse: true },
|
||||
updating: { tone: 'starting', label: 'Updating', pulse: true },
|
||||
}
|
||||
|
||||
const st = computed<StatusEntry>(() => STATUS_MAP[props.status ?? 'online'] ?? DEFAULT_STATUS)
|
||||
const offline = computed(() => props.status === 'offline')
|
||||
|
||||
const statList = computed<StatItem[]>(() => {
|
||||
if (props.stats) return props.stats
|
||||
const items: StatItem[] = [
|
||||
{ label: 'Players', value: `${props.players?.cur ?? 0} / ${props.players?.max ?? 0}` },
|
||||
]
|
||||
if (props.version) {
|
||||
items.push({ label: 'Build', value: props.version })
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const pending = computed(
|
||||
() => props.status === 'online' && props.cpu == null && props.ram == null,
|
||||
)
|
||||
|
||||
const showMeters = computed(
|
||||
() => !offline.value && (props.cpu != null || props.ram != null),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:data-game="game"
|
||||
:class="['cc-server', offline && 'cc-server--offline']"
|
||||
>
|
||||
<!-- Head -->
|
||||
<div class="cc-server__head">
|
||||
<div class="cc-server__game">
|
||||
<Icon :name="gameIcon" :size="18" :stroke-width="2" />
|
||||
</div>
|
||||
<div class="cc-server__id">
|
||||
<div class="cc-server__name">
|
||||
{{ name }}
|
||||
<Badge :tone="st.tone" dot :pulse="st.pulse">{{ st.label }}</Badge>
|
||||
</div>
|
||||
<div class="cc-server__meta">
|
||||
<span v-if="region">{{ region }}</span>
|
||||
<span v-if="map">{{ map }}</span>
|
||||
<span v-if="ip">{{ ip }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-server__actions">
|
||||
<IconButton icon="terminal" variant="ghost" size="sm" label="Console" @click="emit('console')" />
|
||||
<IconButton icon="settings" variant="ghost" size="sm" label="Settings" @click="emit('settings')" />
|
||||
<IconButton
|
||||
:icon="offline ? 'play' : 'power'"
|
||||
:variant="offline ? 'accent' : 'ghost'"
|
||||
size="sm"
|
||||
:label="offline ? 'Start' : 'Power'"
|
||||
@click="emit('power')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="cc-server__body">
|
||||
<div class="cc-server__stats">
|
||||
<div v-for="(s, i) in statList" :key="i" class="cc-server__stat">
|
||||
<b>{{ s.value }}</b>
|
||||
<span>{{ s.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showMeters" class="cc-server__meters">
|
||||
<ResourceMeter v-if="cpu != null" label="CPU" :value="cpu" />
|
||||
<ResourceMeter v-if="ram != null" label="RAM" :value="ram" :sub="ramSub" />
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="cc-server__pending">
|
||||
<Icon name="loader" :size="13" :stroke-width="2.5" />
|
||||
Telemetry pending · agent monitoring
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-server { position: relative; background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default); overflow: hidden; transition: var(--transition-colors); }
|
||||
.cc-server::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); opacity: .9; }
|
||||
.cc-server:hover { box-shadow: inset 0 0 0 1px var(--border-strong); }
|
||||
.cc-server--offline::before { background: var(--status-offline); }
|
||||
.cc-server--offline { opacity: .82; }
|
||||
.cc-server__head { display: flex; align-items: center; gap: 12px; padding: 14px 14px 12px 17px; }
|
||||
.cc-server__game { width: 34px; height: 34px; border-radius: var(--radius-md); flex: none; display: flex; align-items: center; justify-content: center; color: var(--accent); background: var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent-border); }
|
||||
.cc-server__id { flex: 1; min-width: 0; }
|
||||
.cc-server__name { font-size: var(--text-base); font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 8px; }
|
||||
.cc-server__meta { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; display: flex; gap: 10px; }
|
||||
.cc-server__body { padding: 0 14px 13px 17px; display: flex; flex-direction: column; gap: 11px; }
|
||||
.cc-server__stats { display: flex; gap: 18px; }
|
||||
.cc-server__stat { display: flex; flex-direction: column; gap: 1px; }
|
||||
.cc-server__stat b { font-family: var(--font-mono); font-weight: 600; font-size: var(--text-sm); color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||
.cc-server__stat span { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.cc-server__meters { display: flex; gap: 14px; }
|
||||
.cc-server__meters > * { flex: 1; }
|
||||
.cc-server__pending { display: flex; align-items: center; gap: 7px; font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); }
|
||||
.cc-server__pending .cc-icon { color: var(--status-starting); }
|
||||
.cc-server__actions { display: flex; gap: 5px; }
|
||||
</style>
|
||||
62
frontend/src/components/ds/data/StatCard.vue
Normal file
62
frontend/src/components/ds/data/StatCard.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* StatCard — KPI tile with icon, big mono value, and optional delta + note row.
|
||||
* Green delta = up/good, red = down/bad, muted = flat.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
value: string | number
|
||||
unit?: string
|
||||
icon?: string
|
||||
delta?: string | number
|
||||
deltaDir?: 'up' | 'down' | 'flat'
|
||||
note?: string
|
||||
}>(),
|
||||
{ deltaDir: 'up' },
|
||||
)
|
||||
|
||||
const deltaIcon = computed(() =>
|
||||
props.deltaDir === 'up' ? 'trending-up' : props.deltaDir === 'down' ? 'trending-down' : 'minus',
|
||||
)
|
||||
|
||||
const showFoot = computed(() => props.delta != null || !!props.note)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cc-stat">
|
||||
<div class="cc-stat__top">
|
||||
<div v-if="icon" class="cc-stat__ico">
|
||||
<Icon :name="icon" :size="15" :stroke-width="2.25" />
|
||||
</div>
|
||||
<div class="cc-stat__label">{{ label }}</div>
|
||||
</div>
|
||||
<div class="cc-stat__value">
|
||||
{{ value }}<span v-if="unit" class="cc-stat__unit">{{ unit }}</span>
|
||||
</div>
|
||||
<div v-if="showFoot" class="cc-stat__foot">
|
||||
<span v-if="delta != null" :class="['cc-stat__delta', 'cc-stat__delta--' + deltaDir]">
|
||||
<Icon :name="deltaIcon" :size="13" :stroke-width="2.5" />{{ delta }}
|
||||
</span>
|
||||
<span v-if="note" class="cc-stat__note">{{ note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-stat { background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default); padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; min-width: 0; position: relative; overflow: hidden; }
|
||||
.cc-stat__top { display: flex; align-items: center; gap: 8px; }
|
||||
.cc-stat__ico { width: 28px; height: 28px; border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; background: var(--accent-soft); color: var(--accent-text); flex: none; }
|
||||
.cc-stat__label { font-size: var(--text-xs); font-weight: 500; color: var(--text-tertiary); letter-spacing: .01em; }
|
||||
.cc-stat__value { font-family: var(--font-mono); font-weight: 600; font-size: 28px; letter-spacing: -0.02em; color: var(--text-primary); font-variant-numeric: tabular-nums; line-height: 1; display: flex; align-items: baseline; gap: 4px; }
|
||||
.cc-stat__unit { font-size: 14px; color: var(--text-muted); font-weight: 500; }
|
||||
.cc-stat__foot { display: flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
.cc-stat__delta { display: inline-flex; align-items: center; gap: 3px; font-weight: 600; }
|
||||
.cc-stat__delta--up { color: var(--status-online); }
|
||||
.cc-stat__delta--down { color: var(--status-offline); }
|
||||
.cc-stat__delta--flat { color: var(--text-tertiary); }
|
||||
.cc-stat__note { color: var(--text-tertiary); }
|
||||
</style>
|
||||
86
frontend/src/components/ds/feedback/Alert.vue
Normal file
86
frontend/src/components/ds/feedback/Alert.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Alert — contextual inline alert strip.
|
||||
* Tones: info | warn | danger | online | accent | neutral.
|
||||
* Pass `title` for a bold heading, default slot for body text, `actions` slot
|
||||
* for inline action buttons. Set `dismissible` to show an × ghost button that
|
||||
* emits `dismiss`.
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
type Tone = 'info' | 'warn' | 'danger' | 'online' | 'accent' | 'neutral'
|
||||
|
||||
const ICONS: Record<Tone, string> = {
|
||||
info: 'info',
|
||||
warn: 'triangle-alert',
|
||||
danger: 'octagon-alert',
|
||||
online: 'circle-check',
|
||||
accent: 'sparkles',
|
||||
neutral: 'info',
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tone?: Tone
|
||||
title?: string
|
||||
dismissible?: boolean
|
||||
icon?: string
|
||||
}>(),
|
||||
{ tone: 'info', dismissible: false },
|
||||
)
|
||||
|
||||
defineEmits<{ dismiss: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="['cc-alert', 'cc-alert--' + tone]"
|
||||
role="status"
|
||||
>
|
||||
<span class="cc-alert__icon">
|
||||
<Icon :name="icon ?? ICONS[tone]" :size="17" :stroke-width="2" />
|
||||
</span>
|
||||
<div class="cc-alert__main">
|
||||
<div v-if="title" class="cc-alert__title">{{ title }}</div>
|
||||
<div v-if="$slots.default" class="cc-alert__body"><slot /></div>
|
||||
<div v-if="$slots.actions" class="cc-alert__actions"><slot name="actions" /></div>
|
||||
</div>
|
||||
<button
|
||||
v-if="dismissible"
|
||||
class="cc-alert__dismiss"
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
@click="$emit('dismiss')"
|
||||
>
|
||||
<Icon name="x" :size="15" :stroke-width="2.25" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-alert { display:flex; gap:11px; padding:12px 13px; border-radius:var(--radius-md); background:var(--surface-raised); box-shadow:var(--ring-default); }
|
||||
.cc-alert__icon { flex:none; margin-top:1px; }
|
||||
.cc-alert__main { flex:1; min-width:0; display:flex; flex-direction:column; gap:3px; }
|
||||
.cc-alert__title { font-size:var(--text-sm); font-weight:600; color:var(--text-primary); }
|
||||
.cc-alert__body { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.5; }
|
||||
.cc-alert__actions { display:flex; gap:8px; margin-top:8px; }
|
||||
.cc-alert--info { background:var(--status-info-soft); box-shadow: inset 0 0 0 1px var(--status-info-border); }
|
||||
.cc-alert--info .cc-alert__icon { color:var(--status-info); }
|
||||
.cc-alert--warn { background:var(--status-warn-soft); box-shadow: inset 0 0 0 1px var(--status-warn-border); }
|
||||
.cc-alert--warn .cc-alert__icon { color:var(--status-warn); }
|
||||
.cc-alert--danger { background:var(--status-offline-soft); box-shadow: inset 0 0 0 1px var(--status-offline-border); }
|
||||
.cc-alert--danger .cc-alert__icon { color:var(--status-offline); }
|
||||
.cc-alert--online { background:var(--status-online-soft); box-shadow: inset 0 0 0 1px var(--status-online-border); }
|
||||
.cc-alert--online .cc-alert__icon { color:var(--status-online); }
|
||||
.cc-alert--accent { background:var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent-border); }
|
||||
.cc-alert--accent .cc-alert__icon { color:var(--accent-text); }
|
||||
.cc-alert__dismiss {
|
||||
flex: none; display:inline-flex; align-items:center; justify-content:center;
|
||||
width:26px; height:26px; border-radius:var(--radius-sm); border:none; cursor:pointer;
|
||||
background:transparent; color:var(--text-secondary);
|
||||
transition: var(--transition-colors);
|
||||
margin-top:-3px; margin-right:-3px;
|
||||
}
|
||||
.cc-alert__dismiss:hover { background:var(--surface-hover); color:var(--text-primary); }
|
||||
.cc-alert__dismiss:focus-visible { outline:none; box-shadow:var(--focus-ring); }
|
||||
</style>
|
||||
39
frontend/src/components/ds/feedback/EmptyState.vue
Normal file
39
frontend/src/components/ds/feedback/EmptyState.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* EmptyState — zero-data placeholder with icon, title, description, and an
|
||||
* optional action slot (pass a Button or link).
|
||||
*
|
||||
* Icon registry note: default icon 'inbox' is not in the registry — it will
|
||||
* silently not render per Icon.vue's null guard. Callers should pass a
|
||||
* registered icon name (e.g. icon="server", icon="folder-open").
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
icon?: string
|
||||
title?: string
|
||||
description?: string
|
||||
}>(),
|
||||
{ icon: 'inbox' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cc-empty">
|
||||
<div class="cc-empty__icon">
|
||||
<Icon :name="icon" :size="22" :stroke-width="1.75" />
|
||||
</div>
|
||||
<div v-if="title" class="cc-empty__title">{{ title }}</div>
|
||||
<div v-if="description" class="cc-empty__desc">{{ description }}</div>
|
||||
<div v-if="$slots.action" class="cc-empty__action"><slot name="action" /></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-empty { display:flex; flex-direction:column; align-items:center; text-align:center; gap:5px; padding:36px 24px; }
|
||||
.cc-empty__icon { width:46px; height:46px; border-radius:var(--radius-lg); display:flex; align-items:center; justify-content:center; margin-bottom:8px; color:var(--text-tertiary); background:var(--surface-raised-2); box-shadow:var(--ring-default); }
|
||||
.cc-empty__title { font-size:var(--text-base); font-weight:600; color:var(--text-primary); }
|
||||
.cc-empty__desc { font-size:var(--text-sm); color:var(--text-tertiary); max-width:340px; line-height:1.5; }
|
||||
.cc-empty__action { margin-top:12px; }
|
||||
</style>
|
||||
50
frontend/src/components/ds/forms/Checkbox.vue
Normal file
50
frontend/src/components/ds/forms/Checkbox.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Checkbox — square toggle; checked/indeterminate state carries the live game accent.
|
||||
* v-model binds to boolean checked state via defineModel<boolean>().
|
||||
* The hidden <input type="checkbox"> drives CSS :checked/:indeterminate/:focus-visible/:disabled.
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
}>(),
|
||||
{ disabled: false },
|
||||
)
|
||||
|
||||
const model = defineModel<boolean>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="cc-check" :for="id">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="id"
|
||||
:checked="model"
|
||||
:disabled="disabled"
|
||||
@change="model = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
<span class="cc-check__box">
|
||||
<Icon name="check" :size="12" :stroke-width="3" />
|
||||
</span>
|
||||
<span v-if="label" class="cc-check__label">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-check { display:inline-flex; align-items:center; gap:9px; cursor:pointer; user-select:none; }
|
||||
.cc-check input { position:absolute; opacity:0; width:0; height:0; }
|
||||
.cc-check__box {
|
||||
width:17px; height:17px; flex:none; border-radius:var(--radius-xs); background:var(--surface-inset);
|
||||
box-shadow: inset 0 0 0 1px var(--border-strong); display:flex; align-items:center; justify-content:center;
|
||||
color:transparent; transition:var(--transition-colors);
|
||||
}
|
||||
.cc-check input:checked + .cc-check__box { background:var(--accent); box-shadow: inset 0 0 0 1px var(--accent); color:var(--accent-contrast); }
|
||||
.cc-check input:indeterminate + .cc-check__box { background:var(--accent); box-shadow: inset 0 0 0 1px var(--accent); color:var(--accent-contrast); }
|
||||
.cc-check input:focus-visible + .cc-check__box { box-shadow: var(--focus-ring); }
|
||||
.cc-check input:disabled + .cc-check__box { opacity:.5; }
|
||||
.cc-check__label { font-size:var(--text-sm); color:var(--text-primary); }
|
||||
</style>
|
||||
90
frontend/src/components/ds/forms/Input.vue
Normal file
90
frontend/src/components/ds/forms/Input.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Input — text field with label, hint/error, leading icon and affixes.
|
||||
* Reach for `mono` on any technical value (ports, tokens, IDs).
|
||||
* v-model binds to the inner <input> value via defineModel<string>().
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
icon?: string
|
||||
prefix?: string
|
||||
suffix?: string
|
||||
size?: 'md' | 'sm'
|
||||
mono?: boolean
|
||||
required?: boolean
|
||||
id?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
type?: string
|
||||
}>(),
|
||||
{ size: 'md', mono: false, required: false, disabled: false, type: 'text' },
|
||||
)
|
||||
|
||||
const model = defineModel<string>()
|
||||
|
||||
const invalid = () => !!props.error
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
class="cc-field"
|
||||
:for="id"
|
||||
>
|
||||
<span v-if="label" class="cc-field__label">
|
||||
{{ label }}<span v-if="required" class="req">*</span>
|
||||
</span>
|
||||
<span
|
||||
:class="[
|
||||
'cc-input',
|
||||
size === 'sm' && 'cc-input--sm',
|
||||
mono && 'cc-input--mono',
|
||||
invalid() && 'cc-input--invalid',
|
||||
]"
|
||||
>
|
||||
<Icon v-if="icon" :name="icon" :size="15" />
|
||||
<span v-if="prefix" class="cc-input__affix">{{ prefix }}</span>
|
||||
<input
|
||||
:id="id"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:value="model"
|
||||
@input="model = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<span v-if="suffix" class="cc-input__affix">{{ suffix }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="hint || error"
|
||||
:class="['cc-field__hint', invalid() && 'cc-field__hint--error']"
|
||||
>{{ error ?? hint }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-field { display:flex; flex-direction:column; gap:6px; }
|
||||
.cc-field__label { font-size:var(--text-xs); font-weight:600; color:var(--text-secondary); }
|
||||
.cc-field__label .req { color:var(--accent-text); margin-left:2px; }
|
||||
.cc-input {
|
||||
display:flex; align-items:center; gap:8px; height:var(--control-h-md); padding:0 11px;
|
||||
background:var(--surface-inset); border-radius:var(--radius-md); box-shadow:var(--ring-default);
|
||||
transition:var(--transition-colors); color:var(--text-tertiary);
|
||||
}
|
||||
.cc-input:focus-within { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
.cc-input--sm { height:var(--control-h-sm); padding:0 9px; }
|
||||
.cc-input--invalid { box-shadow: inset 0 0 0 1px var(--status-offline-border); }
|
||||
.cc-input--invalid:focus-within { box-shadow: inset 0 0 0 1px var(--danger); }
|
||||
.cc-input input {
|
||||
flex:1; min-width:0; background:transparent; border:0; outline:0; padding:0; margin:0;
|
||||
font-family:var(--font-sans); font-size:var(--text-sm); color:var(--text-primary);
|
||||
}
|
||||
.cc-input input::placeholder { color:var(--text-muted); }
|
||||
.cc-input--mono input { font-family:var(--font-mono); }
|
||||
.cc-input__affix { font-family:var(--font-mono); font-size:var(--text-xs); color:var(--text-muted); white-space:nowrap; }
|
||||
.cc-field__hint { font-size:var(--text-xs); color:var(--text-tertiary); }
|
||||
.cc-field__hint--error { color:var(--danger); }
|
||||
</style>
|
||||
86
frontend/src/components/ds/forms/Select.vue
Normal file
86
frontend/src/components/ds/forms/Select.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Select — styled native <select> with chevron overlay.
|
||||
* With `label` the root becomes a <label> wrapping the control.
|
||||
* v-model binds to the selected value via defineModel<string>().
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
type SelectOption = string | { value: string; label: string }
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
options?: SelectOption[]
|
||||
size?: 'md' | 'sm'
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ options: () => [], size: 'md', disabled: false },
|
||||
)
|
||||
|
||||
const model = defineModel<string>()
|
||||
|
||||
function optionValue(o: SelectOption): string {
|
||||
return typeof o === 'string' ? o : o.value
|
||||
}
|
||||
function optionLabel(o: SelectOption): string {
|
||||
return typeof o === 'string' ? o : o.label
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- With label: wrap in <label> matching React's cc-field layout -->
|
||||
<label v-if="label" class="cc-field" :for="id">
|
||||
<span class="cc-field__label">{{ label }}</span>
|
||||
<span :class="['cc-select', size === 'sm' && 'cc-select--sm']">
|
||||
<select
|
||||
:id="id"
|
||||
:disabled="disabled"
|
||||
:value="model"
|
||||
@change="model = ($event.target as HTMLSelectElement).value"
|
||||
>
|
||||
<option
|
||||
v-for="(o, i) in options"
|
||||
:key="i"
|
||||
:value="optionValue(o)"
|
||||
>{{ optionLabel(o) }}</option>
|
||||
</select>
|
||||
<span class="cc-select__chev">
|
||||
<Icon name="chevron-down" :size="15" />
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Without label: bare control -->
|
||||
<span v-else :class="['cc-select', size === 'sm' && 'cc-select--sm']">
|
||||
<select
|
||||
:id="id"
|
||||
:disabled="disabled"
|
||||
:value="model"
|
||||
@change="model = ($event.target as HTMLSelectElement).value"
|
||||
>
|
||||
<option
|
||||
v-for="(o, i) in options"
|
||||
:key="i"
|
||||
:value="optionValue(o)"
|
||||
>{{ optionLabel(o) }}</option>
|
||||
</select>
|
||||
<span class="cc-select__chev">
|
||||
<Icon name="chevron-down" :size="15" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-select { position:relative; display:flex; align-items:center; }
|
||||
.cc-select select {
|
||||
appearance:none; width:100%; height:var(--control-h-md); padding:0 32px 0 11px;
|
||||
background:var(--surface-inset); color:var(--text-primary); border:0; border-radius:var(--radius-md);
|
||||
box-shadow:var(--ring-default); font-family:var(--font-sans); font-size:var(--text-sm); cursor:pointer;
|
||||
transition:var(--transition-colors); outline:0;
|
||||
}
|
||||
.cc-select select:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
.cc-select--sm select { height:var(--control-h-sm); padding:0 28px 0 9px; }
|
||||
.cc-select__chev { position:absolute; right:9px; pointer-events:none; color:var(--text-tertiary); display:flex; }
|
||||
</style>
|
||||
59
frontend/src/components/ds/forms/Switch.vue
Normal file
59
frontend/src/components/ds/forms/Switch.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Switch — toggle control; checked state carries the live game accent.
|
||||
* v-model binds to boolean checked state via defineModel<boolean>().
|
||||
* The hidden <input type="checkbox"> drives CSS :checked/:focus-visible/:disabled.
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
size?: 'md' | 'sm'
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
}>(),
|
||||
{ size: 'md', disabled: false },
|
||||
)
|
||||
|
||||
const model = defineModel<boolean>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
:class="['cc-switch', size === 'sm' && 'cc-switch--sm']"
|
||||
:for="id"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="id"
|
||||
:checked="model"
|
||||
:disabled="disabled"
|
||||
@change="model = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
<span class="cc-switch__track">
|
||||
<span class="cc-switch__thumb" />
|
||||
</span>
|
||||
<span v-if="label" class="cc-switch__label">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-switch { display:inline-flex; align-items:center; gap:10px; cursor:pointer; user-select:none; }
|
||||
.cc-switch input { position:absolute; opacity:0; width:0; height:0; }
|
||||
.cc-switch__track {
|
||||
position:relative; width:36px; height:20px; border-radius:var(--radius-pill); flex:none;
|
||||
background:var(--surface-active); box-shadow: inset 0 0 0 1px var(--border-default);
|
||||
transition: background var(--dur-base) var(--ease-standard), box-shadow var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
.cc-switch__thumb {
|
||||
position:absolute; top:2px; left:2px; width:16px; height:16px; border-radius:50%;
|
||||
background:var(--text-secondary); transition: transform var(--dur-base) var(--ease-emphasized), background var(--dur-base);
|
||||
}
|
||||
.cc-switch input:checked + .cc-switch__track { background:var(--accent); box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
.cc-switch input:checked + .cc-switch__track .cc-switch__thumb { transform:translateX(16px); background:var(--accent-contrast); }
|
||||
.cc-switch input:focus-visible + .cc-switch__track { box-shadow: var(--focus-ring); }
|
||||
.cc-switch input:disabled + .cc-switch__track { opacity:.5; }
|
||||
.cc-switch__label { font-size:var(--text-sm); color:var(--text-primary); }
|
||||
.cc-switch--sm .cc-switch__track { width:30px; height:17px; }
|
||||
.cc-switch--sm .cc-switch__thumb { width:13px; height:13px; }
|
||||
.cc-switch--sm input:checked + .cc-switch__track .cc-switch__thumb { transform:translateX(13px); }
|
||||
</style>
|
||||
71
frontend/src/components/ds/navigation/GameSwitcher.vue
Normal file
71
frontend/src/components/ds/navigation/GameSwitcher.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* GameSwitcher — segmented control for switching the active game context.
|
||||
* Set `data-game` on a root shell element to the chosen key so the global
|
||||
* [data-game] CSS custom properties re-skin the entire panel.
|
||||
* Per-game accent comes from var(--accent) which is resolved by the [data-game] token scope.
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
export interface GameOption {
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
games: (string | GameOption)[]
|
||||
showLabels?: boolean
|
||||
class?: string
|
||||
}>(),
|
||||
{ showLabels: true },
|
||||
)
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
function normalise(g: string | GameOption): GameOption {
|
||||
return typeof g === 'string' ? { key: g, label: g } : g
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['cc-gameswitch', props.class]" role="group">
|
||||
<button
|
||||
v-for="raw in games"
|
||||
:key="normalise(raw).key"
|
||||
type="button"
|
||||
:aria-pressed="normalise(raw).key === model"
|
||||
:data-game="normalise(raw).key"
|
||||
class="cc-gameswitch__opt"
|
||||
:title="normalise(raw).label"
|
||||
@click="model = normalise(raw).key"
|
||||
>
|
||||
<Icon
|
||||
v-if="normalise(raw).icon"
|
||||
:name="normalise(raw).icon ?? ''"
|
||||
:size="14"
|
||||
:stroke-width="2.25"
|
||||
:style="{ color: normalise(raw).key === model ? 'var(--accent)' : 'inherit' }"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="cc-gameswitch__dot"
|
||||
:style="{ background: normalise(raw).key === model ? 'var(--accent)' : 'var(--text-muted)' }"
|
||||
/>
|
||||
<span v-if="showLabels">{{ normalise(raw).label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-gameswitch { display:inline-flex; align-items:center; gap:3px; padding:3px; background:var(--surface-inset); border-radius:var(--radius-md); box-shadow:var(--ring-default); }
|
||||
.cc-gameswitch__opt {
|
||||
display:inline-flex; align-items:center; gap:7px; height:28px; padding:0 11px; border:0; background:transparent;
|
||||
font-family:var(--font-sans); font-size:var(--text-xs); font-weight:600; color:var(--text-tertiary);
|
||||
border-radius:var(--radius-sm); cursor:pointer; transition:var(--transition-colors); white-space:nowrap;
|
||||
}
|
||||
.cc-gameswitch__opt:hover { color:var(--text-primary); }
|
||||
.cc-gameswitch__dot { width:8px; height:8px; border-radius:50%; background:var(--accent); flex:none; }
|
||||
.cc-gameswitch__opt[aria-pressed="true"] { background:var(--surface-raised-2); color:var(--text-primary); box-shadow:var(--ring-default); }
|
||||
</style>
|
||||
50
frontend/src/components/ds/navigation/NavItem.vue
Normal file
50
frontend/src/components/ds/navigation/NavItem.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* NavItem — sidebar navigation row: icon + label, active state with accent rail,
|
||||
* optional trailing count. Collapsed mode renders icon-only at 40 px wide.
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
icon: string
|
||||
label: string
|
||||
active?: boolean
|
||||
count?: number | string
|
||||
collapsed?: boolean
|
||||
}>(),
|
||||
{ active: false, collapsed: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ click: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="['cc-nav', active && 'cc-nav--active', collapsed && 'cc-nav--collapsed']"
|
||||
role="button"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
:title="collapsed ? label : undefined"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<span class="cc-nav__icon">
|
||||
<Icon :name="icon" :size="17" :stroke-width="2" />
|
||||
</span>
|
||||
<span v-if="!collapsed" class="cc-nav__label">{{ label }}</span>
|
||||
<span v-if="!collapsed && count != null" class="cc-nav__count">{{ count }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-nav { display:flex; align-items:center; gap:10px; height:34px; padding:0 10px; border-radius:var(--radius-md);
|
||||
color:var(--text-secondary); cursor:pointer; transition:var(--transition-colors); position:relative; user-select:none; }
|
||||
.cc-nav:hover { background:var(--surface-hover); color:var(--text-primary); }
|
||||
.cc-nav__icon { flex:none; color:var(--text-tertiary); display:flex; transition:var(--transition-colors); }
|
||||
.cc-nav__label { flex:1; font-size:var(--text-sm); font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.cc-nav__count { font-family:var(--font-mono); font-size:11px; color:var(--text-tertiary); padding:1px 6px; border-radius:var(--radius-pill); background:var(--surface-active); }
|
||||
.cc-nav--active { background:var(--accent-soft); color:var(--accent-text); }
|
||||
.cc-nav--active .cc-nav__icon { color:var(--accent-text); }
|
||||
.cc-nav--active::before { content:''; position:absolute; left:-10px; top:7px; bottom:7px; width:3px; border-radius:var(--radius-pill); background:var(--accent); }
|
||||
.cc-nav--active .cc-nav__count { background:var(--accent-soft-strong); color:var(--accent-text); }
|
||||
.cc-nav--collapsed { justify-content:center; padding:0; width:40px; }
|
||||
</style>
|
||||
67
frontend/src/components/ds/navigation/Tabs.vue
Normal file
67
frontend/src/components/ds/navigation/Tabs.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Tabs — horizontal tab bar. variant="pill" fills active tab with accent-soft;
|
||||
* variant="line" underlines with accent. Items can be bare strings or TabItem objects.
|
||||
*/
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
export interface TabItem {
|
||||
value: string
|
||||
label: string
|
||||
icon?: string
|
||||
count?: number | string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: (string | TabItem)[]
|
||||
variant?: 'pill' | 'line'
|
||||
class?: string
|
||||
}>(),
|
||||
{ variant: 'pill' },
|
||||
)
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
function normalise(it: string | TabItem): TabItem {
|
||||
return typeof it === 'string' ? { value: it, label: it } : it
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="['cc-tabs', `cc-tabs--${variant}`, props.class]"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
v-for="raw in items"
|
||||
:key="normalise(raw).value"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="normalise(raw).value === model"
|
||||
class="cc-tab"
|
||||
@click="model = normalise(raw).value"
|
||||
>
|
||||
<Icon v-if="normalise(raw).icon" :name="normalise(raw).icon ?? ''" :size="15" />
|
||||
{{ normalise(raw).label }}
|
||||
<span v-if="normalise(raw).count != null" class="cc-tab__count">{{ normalise(raw).count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cc-tabs { display:flex; align-items:center; gap:2px; position:relative; }
|
||||
.cc-tabs--line { box-shadow: inset 0 -1px 0 var(--border-subtle); gap:4px; }
|
||||
.cc-tab {
|
||||
display:inline-flex; align-items:center; gap:7px; height:32px; padding:0 11px; border:0; background:transparent;
|
||||
font-family:var(--font-sans); font-size:var(--text-sm); font-weight:500; color:var(--text-tertiary);
|
||||
cursor:pointer; border-radius:var(--radius-sm); transition:var(--transition-colors); white-space:nowrap; position:relative;
|
||||
}
|
||||
.cc-tab:hover { color:var(--text-primary); background:var(--surface-hover); }
|
||||
.cc-tabs--pill .cc-tab[aria-selected="true"] { color:var(--accent-text); background:var(--accent-soft); }
|
||||
.cc-tabs--line .cc-tab { border-radius:0; height:38px; padding:0 4px; margin:0 7px; }
|
||||
.cc-tabs--line .cc-tab:hover { background:transparent; }
|
||||
.cc-tabs--line .cc-tab[aria-selected="true"] { color:var(--text-primary); box-shadow: inset 0 -2px 0 var(--accent); }
|
||||
.cc-tab__count { font-family:var(--font-mono); font-size:11px; padding:1px 6px; border-radius:var(--radius-pill); background:var(--surface-active); color:var(--text-tertiary); }
|
||||
.cc-tab[aria-selected="true"] .cc-tab__count { background:var(--accent-soft); color:var(--accent-text); }
|
||||
</style>
|
||||
@@ -1,73 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DashboardLayout — game-aware app shell (Phase C redesign).
|
||||
* Replaces the old Tailwind-only sidebar with the DS component set.
|
||||
* Preserves: navSections, permission gating, super-admin section, logout, RouterView.
|
||||
* Adds: GameSwitcher, Logo, DS NavItem, agent-health footer, topbar w/ search + theme toggle.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Terminal,
|
||||
Users,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
Map,
|
||||
MessageSquare,
|
||||
BarChart3,
|
||||
Bell,
|
||||
UserPlus,
|
||||
ShoppingBag,
|
||||
Package,
|
||||
Settings,
|
||||
LogOut,
|
||||
Shield,
|
||||
Key,
|
||||
CreditCard,
|
||||
Network,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Crosshair,
|
||||
Navigation2,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import { useThemeGame } from '@/composables/useThemeGame'
|
||||
import Logo from '@/components/ds/brand/Logo.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Avatar from '@/components/ds/data/Avatar.vue'
|
||||
import NavItem from '@/components/ds/navigation/NavItem.vue'
|
||||
import GameSwitcher from '@/components/ds/navigation/GameSwitcher.vue'
|
||||
import type { GameOption } from '@/components/ds/navigation/GameSwitcher.vue'
|
||||
import type { ActiveGame } from '@/composables/useThemeGame'
|
||||
|
||||
// ---- Stores / composables ----
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const server = useServerStore()
|
||||
const sidebarOpen = ref(false)
|
||||
const { theme, activeGame, setActiveGame, toggleTheme } = useThemeGame()
|
||||
|
||||
const navItems = [
|
||||
{ name: 'Dashboard', path: '/', icon: LayoutDashboard, permission: null },
|
||||
{ name: 'Server', path: '/server', icon: Server, permission: 'server.view' },
|
||||
{ name: 'Console', path: '/console', icon: Terminal, permission: 'console.view' },
|
||||
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
|
||||
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
|
||||
{ name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.view' },
|
||||
{ name: 'Loot Builder', path: '/loot-builder', icon: Crosshair, permission: 'loot.view' },
|
||||
{ name: 'Teleport Config', path: '/teleport-config', icon: Navigation2, permission: 'teleport.view' },
|
||||
{ name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' },
|
||||
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
|
||||
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
|
||||
{ name: 'Analytics', path: '/analytics', icon: BarChart3, permission: 'analytics.view' },
|
||||
{ name: 'Schedules', path: '/schedules', icon: Clock, permission: 'schedules.view' },
|
||||
{ name: 'Alerts', path: '/alerts', icon: AlertTriangle, permission: 'alerts.view' },
|
||||
{ name: 'Notifications', path: '/notifications', icon: Bell, permission: 'notifications.view' },
|
||||
{ name: 'Team', path: '/team', icon: UserPlus, permission: null },
|
||||
{ name: 'Store', path: '/store/config', icon: ShoppingBag, permission: 'store.view' },
|
||||
{ name: 'Modules', path: '/modules', icon: Package, permission: 'modules.view' },
|
||||
{ name: 'Changelog', path: '/changelog', icon: FileText, permission: 'changelog.view' },
|
||||
{ name: 'Settings', path: '/settings', icon: Settings, permission: 'settings.view' },
|
||||
// ---- Mobile sidebar ----
|
||||
const sidebarOpen = ref(false)
|
||||
function closeSidebar() { sidebarOpen.value = false }
|
||||
|
||||
// ---- App version ----
|
||||
const APP_VERSION = '1.0.8'
|
||||
|
||||
// ---- Game switcher ----
|
||||
const GAME_OPTIONS: GameOption[] = [
|
||||
{ key: 'all', label: 'All games', icon: 'layers' },
|
||||
{ key: 'rust', label: 'Rust', icon: 'box' },
|
||||
{ key: 'dune', label: 'Dune', icon: 'sun' },
|
||||
{ key: 'conan', label: 'Conan Exiles', icon: 'swords' },
|
||||
{ key: 'soulmask', label: 'Soulmask', icon: 'drama' },
|
||||
]
|
||||
|
||||
const GAME_LABEL: Record<string, string> = {
|
||||
all: 'All games', rust: 'Rust', dune: 'Dune',
|
||||
conan: 'Conan Exiles', soulmask: 'Soulmask',
|
||||
}
|
||||
|
||||
function onActiveGame(val: string) {
|
||||
setActiveGame(val as ActiveGame)
|
||||
}
|
||||
|
||||
// ---- Navigation ----
|
||||
type NavItemDef = { name: string; path: string; icon: string; permission: string | null }
|
||||
type NavSection = { label: string; items: NavItemDef[] }
|
||||
|
||||
const navSections: NavSection[] = [
|
||||
{
|
||||
label: '',
|
||||
items: [
|
||||
{ name: 'Dashboard', path: '/', icon: 'layout-dashboard', 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: 'folder-open', permission: 'files.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Plugin configs',
|
||||
items: [
|
||||
{ name: 'Plugin configs', path: '/plugin-configs', icon: 'puzzle', permission: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Operations',
|
||||
items: [
|
||||
{ name: 'Wipe manager', path: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
|
||||
{ name: 'Maps', path: '/maps', icon: 'map', permission: 'maps.view' },
|
||||
{ name: 'Schedules', path: '/schedules', icon: 'calendar-clock', permission: 'schedules.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Monitoring',
|
||||
items: [
|
||||
{ name: 'Chat log', path: '/chat', icon: 'message-square', permission: 'chat.view' },
|
||||
{ name: 'Analytics', path: '/analytics', icon: 'bar-chart-3', permission: 'analytics.view' },
|
||||
{ name: 'Alerts', path: '/alerts', icon: 'triangle-alert', permission: 'alerts.view' },
|
||||
{ name: 'Notifications', path: '/notifications', icon: 'bell', permission: 'notifications.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Management',
|
||||
items: [
|
||||
{ name: 'Team', path: '/team', icon: 'users', permission: null },
|
||||
{ name: 'Store', path: '/store/config', icon: 'shopping-cart', permission: 'store.view' },
|
||||
{ name: 'Modules', path: '/modules', icon: 'layers', permission: 'modules.view' },
|
||||
{ name: 'Changelog', path: '/changelog', icon: 'file-text', permission: 'changelog.view' },
|
||||
{ name: 'Settings', path: '/settings', icon: 'settings', permission: 'settings.view' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const adminNavItems = [
|
||||
{ name: 'Admin Home', path: '/admin', icon: Shield },
|
||||
{ name: 'Licenses', path: '/admin/licenses', icon: Key },
|
||||
{ name: 'Subscriptions', path: '/admin/subscriptions', icon: CreditCard },
|
||||
{ name: 'Users', path: '/admin/users', icon: Users },
|
||||
{ name: 'Server Fleet', path: '/admin/servers', icon: Network },
|
||||
{ name: 'Admin home', path: '/admin', icon: 'shield' },
|
||||
{ name: 'Licenses', path: '/admin/licenses', icon: 'key' },
|
||||
{ name: 'Subscriptions', path: '/admin/subscriptions', icon: 'credit-card' },
|
||||
{ name: 'Users', path: '/admin/users', icon: 'users' },
|
||||
{ name: 'Server fleet', path: '/admin/servers', icon: 'server' },
|
||||
]
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
@@ -75,141 +122,499 @@ function isActive(path: string): boolean {
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
function navigate(path: string) {
|
||||
router.push(path)
|
||||
closeSidebar()
|
||||
}
|
||||
|
||||
function closeSidebar() {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
function canShowNavItem(item: typeof navItems[0]): boolean {
|
||||
function canShowNavItem(item: NavItemDef): boolean {
|
||||
if (!item.permission) return true
|
||||
return auth.hasPermission(item.permission)
|
||||
}
|
||||
|
||||
function hasVisibleItems(section: NavSection): boolean {
|
||||
return section.items.some(canShowNavItem)
|
||||
}
|
||||
|
||||
// ---- Agent health ----
|
||||
const agentTone = computed(() => {
|
||||
const cs = server.connection?.connection_status
|
||||
if (cs === 'connected') return 'online' as const
|
||||
if (cs === 'degraded') return 'warn' as const
|
||||
return 'offline' as const
|
||||
})
|
||||
const agentLabel = computed(() => {
|
||||
const cs = server.connection?.connection_status
|
||||
if (cs === 'connected') return 'Healthy'
|
||||
if (cs === 'degraded') return 'Degraded'
|
||||
return 'Offline'
|
||||
})
|
||||
const agentName = computed(() => {
|
||||
const ip = server.connection?.server_ip
|
||||
return ip ?? 'asgard-01'
|
||||
})
|
||||
|
||||
// ---- Topbar ----
|
||||
const serverName = computed(() => auth.license?.server_name ?? 'Your servers')
|
||||
const userName = computed(() => auth.user?.username ?? '')
|
||||
const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
||||
|
||||
// ---- Import computed from vue (missed above) ----
|
||||
import { computed } from 'vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen bg-neutral-950">
|
||||
<!-- Mobile Hamburger -->
|
||||
<button
|
||||
@click="sidebarOpen = true"
|
||||
class="md:hidden fixed top-4 left-4 z-40 p-2 bg-neutral-900 border border-neutral-800 rounded-lg text-neutral-300 hover:text-oxide-400 transition-colors"
|
||||
>
|
||||
<Menu class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<!-- Sidebar Overlay (Mobile) -->
|
||||
<!-- Outer app grid: sidebar | main -->
|
||||
<div class="app">
|
||||
<!-- ===================================================== SIDEBAR ===== -->
|
||||
<!-- Mobile overlay -->
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="sidebar-overlay"
|
||||
@click="closeSidebar"
|
||||
class="md:hidden fixed inset-0 bg-black/50 z-40"
|
||||
/>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="w-64 bg-neutral-900 border-r border-neutral-800 flex flex-col fixed inset-y-0 left-0 z-50 transform transition-transform"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
|
||||
class="app__sidebar"
|
||||
:class="sidebarOpen ? 'app__sidebar--open' : ''"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="p-4 border-b border-neutral-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="/logo.png" alt="Corrosion" class="h-8 w-8" />
|
||||
<div>
|
||||
<h1 class="text-sm font-bold text-oxide-500 tracking-wider">CORROSION</h1>
|
||||
<p class="text-xs text-neutral-500">{{ auth.license?.server_name || 'Server Management' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="closeSidebar"
|
||||
class="md:hidden text-neutral-400 hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Brand -->
|
||||
<div class="side__brand">
|
||||
<Logo :size="22" />
|
||||
<Badge tone="neutral" :mono="true" class="side__ver">{{ APP_VERSION }}</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Server Status Indicator -->
|
||||
<div class="px-4 py-3 border-b border-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="{
|
||||
'bg-green-500': server.connection?.connection_status === 'connected',
|
||||
'bg-yellow-500': server.connection?.connection_status === 'degraded',
|
||||
'bg-red-500': server.connection?.connection_status === 'offline' || !server.connection,
|
||||
}"
|
||||
/>
|
||||
<span class="text-sm text-neutral-400">
|
||||
{{ server.stats?.player_count ?? 0 }}/{{ server.stats?.max_players ?? 0 }} players
|
||||
</span>
|
||||
<!-- Active game switcher -->
|
||||
<div class="side__game">
|
||||
<div class="t-eyebrow side__lbl">
|
||||
Active game · {{ GAME_LABEL[activeGame] ?? 'All games' }}
|
||||
</div>
|
||||
<GameSwitcher
|
||||
:model-value="activeGame"
|
||||
:games="GAME_OPTIONS"
|
||||
:show-labels="false"
|
||||
@update:model-value="onActiveGame"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 overflow-y-auto py-2">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
v-show="canShowNavItem(item)"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
@click="closeSidebar"
|
||||
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
|
||||
:class="isActive(item.path)
|
||||
? 'bg-oxide-500/10 text-oxide-400'
|
||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
||||
>
|
||||
<component :is="item.icon" class="w-4 h-4" />
|
||||
{{ item.name }}
|
||||
</RouterLink>
|
||||
|
||||
<!-- Platform Admin Section (super-admin only) -->
|
||||
<template v-if="auth.isSuperAdmin">
|
||||
<div class="mt-4 mb-2 px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 border-t border-neutral-700" />
|
||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-oxide-500">Platform</span>
|
||||
<div class="flex-1 border-t border-neutral-700" />
|
||||
<nav class="side__nav">
|
||||
<template v-for="section in navSections" :key="section.label">
|
||||
<template v-if="hasVisibleItems(section)">
|
||||
<div class="side__sec">
|
||||
<div v-if="section.label" class="t-eyebrow side__lbl">{{ section.label }}</div>
|
||||
<NavItem
|
||||
v-for="item in section.items"
|
||||
v-show="canShowNavItem(item)"
|
||||
:key="item.path"
|
||||
:icon="item.icon"
|
||||
:label="item.name"
|
||||
:active="isActive(item.path)"
|
||||
@click="navigate(item.path)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Platform admin section (super-admin only) -->
|
||||
<div v-if="auth.isSuperAdmin" class="side__sec">
|
||||
<div class="t-eyebrow side__lbl side__lbl--platform">Platform</div>
|
||||
<NavItem
|
||||
v-for="item in adminNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
@click="closeSidebar"
|
||||
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
|
||||
:class="isActive(item.path)
|
||||
? 'bg-oxide-500/10 text-oxide-400'
|
||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
||||
>
|
||||
<component :is="item.icon" class="w-4 h-4" />
|
||||
{{ item.name }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
:icon="item.icon"
|
||||
:label="item.name"
|
||||
:active="isActive(item.path)"
|
||||
@click="navigate(item.path)"
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- User -->
|
||||
<div class="p-4 border-t border-neutral-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-neutral-300">{{ auth.user?.username }}</p>
|
||||
<p class="text-xs text-neutral-500">{{ auth.user?.email }}</p>
|
||||
<!-- Agent health footer -->
|
||||
<div class="side__foot">
|
||||
<div class="agent">
|
||||
<div class="agent__row">
|
||||
<StatusDot :tone="agentTone" :pulse="agentTone === 'online'" />
|
||||
<span class="agent__name">{{ agentName }}</span>
|
||||
<Badge :tone="agentTone" size="md">{{ agentLabel }}</Badge>
|
||||
</div>
|
||||
<div class="agent__meta">
|
||||
Agent v{{ APP_VERSION }}
|
||||
<template v-if="server.stats"> · {{ server.stats.player_count }}/{{ server.stats.max_players }} players</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- User / logout row -->
|
||||
<div class="side__user">
|
||||
<span class="side__user-name">{{ auth.user?.username ?? '' }}</span>
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="text-neutral-500 hover:text-oxide-400 transition-colors"
|
||||
type="button"
|
||||
class="side__logout"
|
||||
title="Sign out"
|
||||
@click="() => { auth.logout(); router.push('/login') }"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content (offset by sidebar width on desktop) -->
|
||||
<main class="flex-1 overflow-y-auto md:pl-64">
|
||||
<RouterView />
|
||||
</main>
|
||||
<!-- ======================================================= MAIN ===== -->
|
||||
<div class="app__main">
|
||||
<!-- Topbar -->
|
||||
<header class="app__topbar">
|
||||
<!-- Mobile hamburger (left of topbar on small screens) -->
|
||||
<button
|
||||
class="topbar-hamburger"
|
||||
type="button"
|
||||
aria-label="Open navigation"
|
||||
@click="sidebarOpen = true"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<div class="top__crumbs">
|
||||
<span class="crumb">Corrosion</span>
|
||||
<span class="crumb__sep">/</span>
|
||||
<span class="crumb crumb--cluster">{{ serverName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="top__search">
|
||||
<svg class="top__search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input placeholder="Search servers, players, configs…" readonly />
|
||||
<span class="top__kbd">
|
||||
<kbd class="cc-kbd">⌘</kbd><kbd class="cc-kbd">K</kbd>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="top__actions">
|
||||
<IconButton
|
||||
:icon="themeIcon"
|
||||
label="Toggle theme"
|
||||
@click="toggleTheme"
|
||||
/>
|
||||
<IconButton icon="bell" label="Alerts" @click="router.push('/alerts')" />
|
||||
<Button size="sm" icon="rocket">Deploy server</Button>
|
||||
<Avatar
|
||||
:name="userName"
|
||||
:size="30"
|
||||
status="online"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page content -->
|
||||
<main class="app__content">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* ============================================================ SHELL ===== */
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; overflow: hidden; }
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w, 228px) 1fr;
|
||||
height: 100vh;
|
||||
background: var(--surface-canvas, #0a0a0b);
|
||||
}
|
||||
|
||||
/* ---- Sidebar ---- */
|
||||
.app__sidebar {
|
||||
background: var(--surface-base);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.side__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 15px 16px 12px;
|
||||
}
|
||||
|
||||
.side__ver {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.side__game {
|
||||
padding: 2px 14px 13px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.side__lbl {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.side__lbl--platform {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.side__nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 13px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.side__sec {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.side__sec .t-eyebrow {
|
||||
margin: 0 0 5px 10px;
|
||||
}
|
||||
|
||||
.side__foot {
|
||||
padding: 11px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.agent {
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 9px 11px;
|
||||
}
|
||||
|
||||
.agent__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent__name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent__meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 5px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.side__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
padding: 4px 2px 0;
|
||||
}
|
||||
|
||||
.side__user-name {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.side__logout {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-colors);
|
||||
flex: none;
|
||||
}
|
||||
.side__logout:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
|
||||
/* ---- Main ---- */
|
||||
.app__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app__topbar {
|
||||
height: var(--topbar-h, 52px);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
.top__crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.crumb { color: var(--text-tertiary); }
|
||||
.crumb__sep { color: var(--text-muted); }
|
||||
.crumb--cluster {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
background: var(--surface-raised-2);
|
||||
border: 0;
|
||||
box-shadow: var(--ring-default);
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.top__search {
|
||||
flex: 1;
|
||||
max-width: 440px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
height: 34px;
|
||||
padding: 0 11px;
|
||||
background: var(--surface-inset);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.top__search-icon { flex: none; }
|
||||
|
||||
.top__search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.top__search input::placeholder { color: var(--text-muted); }
|
||||
|
||||
.top__kbd { display: flex; gap: 3px; }
|
||||
|
||||
.top__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.app__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 22px 24px 40px;
|
||||
}
|
||||
|
||||
/* ---- Mobile hamburger ---- */
|
||||
.topbar-hamburger {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
.topbar-hamburger:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
|
||||
/* ---- Sidebar overlay (mobile) ---- */
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 49;
|
||||
}
|
||||
|
||||
/* ---- Kbd styling ---- */
|
||||
.cc-kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
background: var(--surface-active);
|
||||
border-radius: var(--radius-xs, 3px);
|
||||
box-shadow: var(--ring-default);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 900px) {
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app__sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 228px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 220ms cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
.app__sidebar--open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.topbar-hamburger {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.top__search {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import Logo from '@/components/ds/brand/Logo.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-neutral-950">
|
||||
<div class="pub-shell">
|
||||
<RouterView />
|
||||
|
||||
<footer class="py-6 text-center text-neutral-600 text-sm border-t border-neutral-800">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<img src="/logo.png" alt="Corrosion" class="h-4 w-4 opacity-60" />
|
||||
<span>Powered by <span class="text-oxide-500 font-semibold">Corrosion</span></span>
|
||||
</div>
|
||||
<footer class="pub-footer">
|
||||
<Logo :size="18" :wordmark="true" />
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pub-shell {
|
||||
min-height: 100vh;
|
||||
background: var(--surface-canvas);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pub-footer {
|
||||
margin-top: auto;
|
||||
padding: var(--space-6) var(--space-6);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { rustContainers, containerCategories } from '@/data/rust-containers'
|
||||
import { Search, Box, Cylinder, Shield, Users, HelpCircle } from 'lucide-vue-next'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import DsInput from '@/components/ds/forms/Input.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
lootTable: Record<string, any>
|
||||
@@ -14,20 +15,21 @@ const emit = defineEmits<{
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
const categoryIcons: Record<string, any> = {
|
||||
crates: Box,
|
||||
barrels: Cylinder,
|
||||
military: Shield,
|
||||
npcs: Users,
|
||||
other: HelpCircle,
|
||||
// Map container categories to DS icon names
|
||||
const categoryIcons: Record<string, string> = {
|
||||
crates: 'box',
|
||||
barrels: 'flask-conical',
|
||||
military: 'shield',
|
||||
npcs: 'users',
|
||||
other: 'info',
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
crates: 'CRATES',
|
||||
barrels: 'BARRELS',
|
||||
military: 'MILITARY',
|
||||
crates: 'Crates',
|
||||
barrels: 'Barrels',
|
||||
military: 'Military',
|
||||
npcs: 'NPCs',
|
||||
other: 'OTHER',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
const filteredContainers = computed(() => {
|
||||
@@ -56,48 +58,136 @@ function isConfigured(prefab: string): boolean {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-64 shrink-0 bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden flex flex-col">
|
||||
<aside class="lcs-root">
|
||||
<!-- Search -->
|
||||
<div class="p-3 border-b border-neutral-800">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search containers..."
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-sm text-neutral-200 placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="lcs-search">
|
||||
<DsInput
|
||||
v-model="searchQuery"
|
||||
icon="search"
|
||||
placeholder="Search containers…"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Container List -->
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
<!-- Container list -->
|
||||
<div class="lcs-list">
|
||||
<template v-for="(containers, category) in groupedContainers" :key="category">
|
||||
<div class="px-3 pt-3 pb-1">
|
||||
<div class="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-widest text-neutral-500">
|
||||
<component :is="categoryIcons[category]" class="w-3 h-3" />
|
||||
{{ categoryLabels[category] || category }}
|
||||
</div>
|
||||
<!-- Category heading -->
|
||||
<div class="lcs-cat">
|
||||
<Icon
|
||||
:name="categoryIcons[category] ?? 'box'"
|
||||
:size="12"
|
||||
class="lcs-cat__icon"
|
||||
/>
|
||||
<span class="lcs-cat__label">{{ categoryLabels[category] ?? category }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Container rows -->
|
||||
<button
|
||||
v-for="c in containers"
|
||||
:key="c.prefab"
|
||||
class="lcs-item"
|
||||
:class="{ 'lcs-item--active': selected === c.prefab }"
|
||||
@click="emit('select', c.prefab)"
|
||||
class="w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 transition-colors"
|
||||
:class="selected === c.prefab
|
||||
? 'bg-oxide-500/10 text-oxide-400'
|
||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
||||
>
|
||||
<span class="truncate flex-1">{{ c.name }}</span>
|
||||
<span
|
||||
v-if="isConfigured(c.prefab)"
|
||||
class="w-1.5 h-1.5 rounded-full bg-oxide-500 shrink-0"
|
||||
/>
|
||||
<span class="lcs-item__name">{{ c.name }}</span>
|
||||
<span v-if="isConfigured(c.prefab)" class="lcs-item__dot" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="Object.keys(groupedContainers).length === 0" class="px-3 py-6 text-center text-neutral-500 text-sm">
|
||||
<div v-if="Object.keys(groupedContainers).length === 0" class="lcs-empty">
|
||||
No containers match
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lcs-root {
|
||||
width: 240px;
|
||||
flex: none;
|
||||
background: var(--surface-base);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--ring-default);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lcs-search {
|
||||
padding: 10px 10px 8px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.lcs-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
/* Category heading */
|
||||
.lcs-cat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px 4px;
|
||||
}
|
||||
.lcs-cat__icon {
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
}
|
||||
.lcs-cat__label {
|
||||
font-size: var(--text-2xs, 10px);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.09em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Container row */
|
||||
.lcs-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
transition: var(--transition-colors);
|
||||
border-radius: 0;
|
||||
}
|
||||
.lcs-item:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lcs-item--active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.lcs-item__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lcs-item__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.lcs-empty {
|
||||
padding: 20px 12px;
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { rustItems } from '@/data/rust-items'
|
||||
import { Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import DsInput from '@/components/ds/forms/Input.vue'
|
||||
import type { LootGroupProfile } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -65,120 +71,260 @@ function updateGroupItemField(groupName: string, shortname: string, field: strin
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Add Group -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
<div class="lge-root">
|
||||
<!-- Add group panel -->
|
||||
<Panel>
|
||||
<div class="lge-add">
|
||||
<DsInput
|
||||
v-model="newGroupName"
|
||||
placeholder="New group name..."
|
||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
||||
placeholder="New group name…"
|
||||
@keydown.enter="addGroup"
|
||||
/>
|
||||
<button
|
||||
@click="addGroup"
|
||||
<Button
|
||||
icon="plus"
|
||||
:disabled="!newGroupName.trim()"
|
||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||
@click="addGroup"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add Group
|
||||
</button>
|
||||
Add group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Group List -->
|
||||
<div v-if="groupEntries.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
|
||||
No loot groups defined. Groups let you create reusable item pools that can be assigned to multiple containers.
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<Panel v-if="groupEntries.length === 0">
|
||||
<EmptyState
|
||||
icon="layers"
|
||||
title="No loot groups"
|
||||
description="Groups let you create reusable item pools that can be assigned to multiple containers."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<!-- Group cards -->
|
||||
<div
|
||||
v-for="entry in groupEntries"
|
||||
:key="entry.name"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden"
|
||||
class="lge-card"
|
||||
>
|
||||
<!-- Group Header -->
|
||||
<!-- Group header -->
|
||||
<button
|
||||
class="lge-card__head"
|
||||
@click="toggleGroup(entry.name)"
|
||||
class="w-full flex items-center justify-between px-4 py-3 hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<component
|
||||
:is="expandedGroup === entry.name ? ChevronDown : ChevronRight"
|
||||
class="w-4 h-4 text-neutral-500"
|
||||
/>
|
||||
<span class="text-neutral-200 font-medium">{{ entry.name }}</span>
|
||||
<span class="text-xs text-neutral-500">{{ entry.itemCount }} items</span>
|
||||
</div>
|
||||
<button
|
||||
<Icon
|
||||
:name="expandedGroup === entry.name ? 'chevron-down' : 'chevron-right'"
|
||||
:size="16"
|
||||
class="lge-card__chevron"
|
||||
/>
|
||||
<span class="lge-card__name">{{ entry.name }}</span>
|
||||
<Badge tone="neutral" mono>{{ entry.itemCount }}</Badge>
|
||||
<IconButton
|
||||
icon="trash-2"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
label="Delete group"
|
||||
class="lge-card__del"
|
||||
@click.stop="deleteGroup(entry.name)"
|
||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Group Items -->
|
||||
<div v-if="expandedGroup === entry.name" class="border-t border-neutral-800 p-4">
|
||||
<table v-if="entry.itemCount > 0" class="w-full text-sm">
|
||||
<!-- Expanded items -->
|
||||
<div v-if="expandedGroup === entry.name" class="lge-card__body">
|
||||
<table v-if="entry.itemCount > 0" class="lge-table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800">
|
||||
<th class="text-left py-2 px-2 text-neutral-500 font-medium">Item</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Min</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Max</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-24">Prob %</th>
|
||||
<th class="w-10"></th>
|
||||
<tr>
|
||||
<th class="lge-th">Item</th>
|
||||
<th class="lge-th lge-th--num">Min</th>
|
||||
<th class="lge-th lge-th--num">Max</th>
|
||||
<th class="lge-th lge-th--num">Prob %</th>
|
||||
<th class="lge-th lge-th--action"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(itemData, shortname) in entry.data.ItemList"
|
||||
:key="shortname"
|
||||
class="border-b border-neutral-800/50"
|
||||
class="lge-tr"
|
||||
>
|
||||
<td class="py-2 px-2 text-neutral-200">{{ getItemName(shortname as string) }}</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lge-td">{{ getItemName(shortname as string) }}</td>
|
||||
<td class="lge-td lge-td--num">
|
||||
<input
|
||||
type="number"
|
||||
:value="(itemData as any).Min ?? 1"
|
||||
@input="updateGroupItemField(entry.name, shortname as string, 'Min', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lge-td lge-td--num">
|
||||
<input
|
||||
type="number"
|
||||
:value="(itemData as any).Max ?? 1"
|
||||
@input="updateGroupItemField(entry.name, shortname as string, 'Max', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lge-td lge-td--num">
|
||||
<input
|
||||
type="number"
|
||||
:value="(itemData as any).Probability ?? 100"
|
||||
@input="updateGroupItemField(entry.name, shortname as string, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<button
|
||||
<td class="lge-td lge-td--action">
|
||||
<IconButton
|
||||
icon="trash-2"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
label="Remove item"
|
||||
@click="removeItemFromGroup(entry.name, shortname as string)"
|
||||
class="text-neutral-600 hover:text-red-400"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="text-neutral-500 text-sm text-center py-4">
|
||||
<p v-else class="lge-card__empty">
|
||||
No items in this group yet. Add items from the container editor.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lge-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Add group row */
|
||||
.lge-add {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.lge-add > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Group card */
|
||||
.lge-card {
|
||||
background: var(--surface-base);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--ring-default);
|
||||
overflow: hidden;
|
||||
}
|
||||
.lge-card__head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
text-align: left;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.lge-card__head:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.lge-card__chevron {
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
}
|
||||
.lge-card__name {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lge-card__del {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Expanded body */
|
||||
.lge-card__body {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 12px;
|
||||
}
|
||||
.lge-card__empty {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
padding: 16px 0 8px;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.lge-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.lge-th {
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lge-th--num {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
.lge-th--action {
|
||||
width: 40px;
|
||||
}
|
||||
.lge-tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.lge-tr:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.lge-tr:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.lge-td {
|
||||
padding: 7px 10px;
|
||||
color: var(--text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.lge-td--num {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
.lge-td--action {
|
||||
width: 40px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/* Shared number input (same as LootItemEditor) */
|
||||
.cc-num-input {
|
||||
width: 100%;
|
||||
background: var(--surface-inset);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 5px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-num-input:focus {
|
||||
box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm);
|
||||
}
|
||||
.cc-num-input--center {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
import { computed } from 'vue'
|
||||
import { rustItems } from '@/data/rust-items'
|
||||
import { rustContainers } from '@/data/rust-containers'
|
||||
import { Trash2, Plus, Settings2 } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Switch from '@/components/ds/forms/Switch.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import type { PrefabLoot } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -76,157 +81,273 @@ const ungroupedItems = computed(() => {
|
||||
...(data as any),
|
||||
}))
|
||||
})
|
||||
|
||||
// Computed boolean for the Switch v-model
|
||||
const isEnabled = computed({
|
||||
get: () => containerData.value?.Enabled ?? true,
|
||||
set: () => toggleEnabled(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Container Header -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold text-neutral-100">{{ containerName }}</h2>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="containerData?.Enabled ?? true"
|
||||
@change="toggleEnabled"
|
||||
class="rounded bg-neutral-800 border-neutral-600 text-oxide-500 focus:ring-oxide-500"
|
||||
/>
|
||||
<span class="text-sm text-neutral-400">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings2 class="w-4 h-4 text-neutral-500" />
|
||||
<span class="text-xs text-neutral-500 font-mono">{{ containerKey.split('/').pop() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lie-root">
|
||||
<!-- Container settings panel -->
|
||||
<Panel :title="containerName">
|
||||
<template #actions>
|
||||
<Badge tone="neutral" mono class="lie-prefab">
|
||||
{{ containerKey.split('/').pop() }}
|
||||
</Badge>
|
||||
<Switch v-model="isEnabled" label="Enabled" size="sm" />
|
||||
</template>
|
||||
|
||||
<!-- Item Settings -->
|
||||
<div class="grid grid-cols-4 gap-3" v-if="containerData">
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Items Min</label>
|
||||
<!-- Item settings grid -->
|
||||
<div v-if="containerData" class="lie-settings">
|
||||
<div class="lie-setting">
|
||||
<label class="lie-setting__label">Items min</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="containerData.ItemSettings?.ItemsMin ?? 1"
|
||||
@input="updateSettings('ItemsMin', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||
class="cc-num-input"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Items Max</label>
|
||||
<div class="lie-setting">
|
||||
<label class="lie-setting__label">Items max</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="containerData.ItemSettings?.ItemsMax ?? 6"
|
||||
@input="updateSettings('ItemsMax', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||
class="cc-num-input"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Min Scrap</label>
|
||||
<div class="lie-setting">
|
||||
<label class="lie-setting__label">Min scrap</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="containerData.ItemSettings?.MinScrap ?? 0"
|
||||
@input="updateSettings('MinScrap', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||
class="cc-num-input"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Max Scrap</label>
|
||||
<div class="lie-setting">
|
||||
<label class="lie-setting__label">Max scrap</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="containerData.ItemSettings?.MaxScrap ?? 0"
|
||||
@input="updateSettings('MaxScrap', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||
class="cc-num-input"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="lie-unconfigured">
|
||||
Container not yet configured. Add an item to initialise its settings.
|
||||
</p>
|
||||
</Panel>
|
||||
|
||||
<!-- Ungrouped Items Table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-neutral-300">Ungrouped Items</h3>
|
||||
<button
|
||||
@click="emit('add-item')"
|
||||
class="flex items-center gap-1 px-3 py-1.5 bg-oxide-500/10 text-oxide-400 rounded-lg hover:bg-oxide-500/20 text-sm"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
<!-- Ungrouped items panel -->
|
||||
<Panel title="Ungrouped items" :flush-body="ungroupedItems.length > 0">
|
||||
<template #actions>
|
||||
<Button size="sm" variant="outline" icon="plus" @click="emit('add-item')">
|
||||
Add item
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div v-if="ungroupedItems.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<div v-if="ungroupedItems.length > 0" class="lie-table-wrap">
|
||||
<table class="lie-table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800">
|
||||
<th class="text-left py-2 px-2 text-neutral-500 font-medium">Item</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Min</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Max</th>
|
||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-24">Prob %</th>
|
||||
<th class="w-10"></th>
|
||||
<tr>
|
||||
<th class="lie-th">Item</th>
|
||||
<th class="lie-th lie-th--num">Min</th>
|
||||
<th class="lie-th lie-th--num">Max</th>
|
||||
<th class="lie-th lie-th--num">Prob %</th>
|
||||
<th class="lie-th lie-th--action"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in ungroupedItems"
|
||||
:key="item.shortname"
|
||||
class="border-b border-neutral-800/50 hover:bg-neutral-800/30"
|
||||
class="lie-tr"
|
||||
>
|
||||
<td class="py-2 px-2">
|
||||
<div>
|
||||
<span class="text-neutral-200">{{ item.name }}</span>
|
||||
<span class="text-neutral-600 text-xs ml-2">{{ item.shortname }}</span>
|
||||
</div>
|
||||
<td class="lie-td">
|
||||
<span class="lie-item-name">{{ item.name }}</span>
|
||||
<span class="lie-item-short">{{ item.shortname }}</span>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lie-td lie-td--num">
|
||||
<input
|
||||
type="number"
|
||||
:value="item.Min"
|
||||
@input="updateItemField(item.shortname, 'Min', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lie-td lie-td--num">
|
||||
<input
|
||||
type="number"
|
||||
:value="item.Max"
|
||||
@input="updateItemField(item.shortname, 'Max', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lie-td lie-td--num">
|
||||
<input
|
||||
type="number"
|
||||
:value="item.Probability ?? 100"
|
||||
@input="updateItemField(item.shortname, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<button
|
||||
<td class="lie-td lie-td--action">
|
||||
<IconButton
|
||||
icon="trash-2"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
label="Remove item"
|
||||
@click="removeItem(item.shortname)"
|
||||
class="text-neutral-600 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-6 text-neutral-500 text-sm">
|
||||
No items configured for this container.
|
||||
<button @click="emit('add-item')" class="text-oxide-400 hover:underline ml-1">Add one</button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
icon="package"
|
||||
title="No items configured"
|
||||
description="Add items to configure what this container can spawn."
|
||||
>
|
||||
<template #action>
|
||||
<Button size="sm" variant="outline" icon="plus" @click="emit('add-item')">
|
||||
Add item
|
||||
</Button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lie-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Badge for prefab key */
|
||||
.lie-prefab {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.lie-unconfigured {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Settings grid */
|
||||
.lie-settings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.lie-setting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.lie-setting__label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.lie-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.lie-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.lie-th {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lie-th--num {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
.lie-th--action {
|
||||
width: 40px;
|
||||
}
|
||||
.lie-tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.lie-tr:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.lie-tr:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.lie-td {
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.lie-td--num {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
.lie-td--action {
|
||||
width: 40px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.lie-item-name {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lie-item-short {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Shared number input */
|
||||
.cc-num-input {
|
||||
width: 100%;
|
||||
background: var(--surface-inset);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 5px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-num-input:focus {
|
||||
box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm);
|
||||
}
|
||||
.cc-num-input--center {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { rustItems, itemCategories } from '@/data/rust-items'
|
||||
import { Search, X } from 'lucide-vue-next'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import DsInput from '@/components/ds/forms/Input.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [shortname: string]
|
||||
@@ -25,64 +27,200 @@ const filteredItems = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="emit('close')">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-neutral-800 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-neutral-100">Add Item</h2>
|
||||
<button @click="emit('close')" class="text-neutral-500 hover:text-neutral-300">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div class="lip-overlay" @click.self="emit('close')">
|
||||
<div class="lip-modal">
|
||||
<!-- Header -->
|
||||
<div class="lip-head">
|
||||
<span class="lip-head__title">Add item</span>
|
||||
<IconButton icon="x" size="sm" label="Close" @click="emit('close')" />
|
||||
</div>
|
||||
|
||||
<!-- Search + Filter -->
|
||||
<div class="p-4 space-y-3 border-b border-neutral-800">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
<!-- Search + category filter -->
|
||||
<div class="lip-filters">
|
||||
<DsInput
|
||||
v-model="searchQuery"
|
||||
placeholder="Search items..."
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-sm text-neutral-200"
|
||||
autofocus
|
||||
icon="search"
|
||||
placeholder="Search items…"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="lip-cats">
|
||||
<button
|
||||
class="lip-cat"
|
||||
:class="{ 'lip-cat--active': selectedCategory === 'all' }"
|
||||
@click="selectedCategory = 'all'"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
v-for="cat in itemCategories"
|
||||
:key="cat"
|
||||
class="lip-cat"
|
||||
:class="{ 'lip-cat--active': selectedCategory === cat }"
|
||||
@click="selectedCategory = cat"
|
||||
>
|
||||
{{ cat }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
@click="selectedCategory = 'all'"
|
||||
class="px-2 py-1 rounded text-xs"
|
||||
:class="selectedCategory === 'all' ? 'bg-oxide-500 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
v-for="cat in itemCategories"
|
||||
:key="cat"
|
||||
@click="selectedCategory = cat"
|
||||
class="px-2 py-1 rounded text-xs capitalize"
|
||||
:class="selectedCategory === cat ? 'bg-oxide-500 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
{{ cat }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Grid -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="item in filteredItems"
|
||||
:key="item.shortname"
|
||||
@click="emit('select', item.shortname)"
|
||||
class="text-left px-3 py-2 bg-neutral-800 rounded-lg hover:bg-neutral-700 transition-colors group"
|
||||
>
|
||||
<div class="text-sm text-neutral-200 group-hover:text-oxide-400">{{ item.name }}</div>
|
||||
<div class="text-xs text-neutral-500">{{ item.shortname }}</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="filteredItems.length === 0" class="text-center py-8 text-neutral-500">
|
||||
No items found
|
||||
<!-- Item grid -->
|
||||
<div class="lip-grid-wrap">
|
||||
<div v-if="filteredItems.length > 0" class="lip-grid">
|
||||
<button
|
||||
v-for="item in filteredItems"
|
||||
:key="item.shortname"
|
||||
class="lip-item"
|
||||
@click="emit('select', item.shortname)"
|
||||
>
|
||||
<span class="lip-item__name">{{ item.name }}</span>
|
||||
<span class="lip-item__short">{{ item.shortname }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="lip-empty">
|
||||
<Icon name="search" :size="20" class="lip-empty__icon" />
|
||||
<span>No items found</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lip-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.lip-modal {
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.lip-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex: none;
|
||||
}
|
||||
.lip-head__title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.lip-filters {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
}
|
||||
.lip-cats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.lip-cat {
|
||||
background: var(--surface-inset);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 3px 10px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.lip-cat:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lip-cat--active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
box-shadow: none;
|
||||
}
|
||||
.lip-cat--active:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.lip-grid-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.lip-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.lip-item {
|
||||
background: var(--surface-inset);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 9px 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-colors);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.lip-item:hover {
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.lip-item__name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.lip-item:hover .lip-item__name {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.lip-item__short {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Empty */
|
||||
.lip-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 48px 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.lip-empty__icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
configData: Record<string, any>
|
||||
@@ -47,7 +49,6 @@ function ensurePaths(data: Record<string, any>) {
|
||||
function addGroup() {
|
||||
const name = newGroupName.value.trim()
|
||||
if (!name) return
|
||||
// Check if already exists
|
||||
if (groups.value.some(g => g.name === name)) return
|
||||
|
||||
const updated = { ...props.configData }
|
||||
@@ -95,96 +96,95 @@ function updateField(groupName: string, field: string, value: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">VIP Permission Groups</h3>
|
||||
<div class="pge">
|
||||
<div class="pge__head">
|
||||
<div class="pge__section-label">VIP permission groups</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Group -->
|
||||
<div class="flex gap-2">
|
||||
<!-- Add group row -->
|
||||
<div class="pge__add-row">
|
||||
<input
|
||||
v-model="newGroupName"
|
||||
placeholder="New group name (e.g. vip, vip+, mvp)..."
|
||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
||||
type="text"
|
||||
class="pge__name-input"
|
||||
placeholder="New group name (e.g. vip, vip+, mvp)…"
|
||||
@keydown.enter="addGroup"
|
||||
/>
|
||||
<button
|
||||
@click="addGroup"
|
||||
<Button
|
||||
size="sm"
|
||||
icon="plus"
|
||||
:disabled="!newGroupName.trim()"
|
||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add Group
|
||||
</button>
|
||||
@click="addGroup"
|
||||
>Add group</Button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="groups.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
|
||||
No VIP groups defined. Add groups to configure per-permission teleport limits, cooldowns, and countdowns.
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<EmptyState
|
||||
v-if="groups.length === 0"
|
||||
icon="users"
|
||||
title="No VIP groups defined"
|
||||
description="Add groups to configure per-permission teleport limits, cooldowns, and countdowns."
|
||||
/>
|
||||
|
||||
<!-- Groups Table -->
|
||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<!-- Groups table -->
|
||||
<div v-else class="pge__table-wrap">
|
||||
<table class="pge__table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800">
|
||||
<th class="text-left py-3 px-4 text-neutral-500 font-medium">Group Name</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Homes Limit</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Cooldown (s)</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Countdown (s)</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Daily Limit</th>
|
||||
<th class="w-12"></th>
|
||||
<tr>
|
||||
<th class="pge__th pge__th--left">Group name</th>
|
||||
<th class="pge__th">Homes limit</th>
|
||||
<th class="pge__th">Cooldown (s)</th>
|
||||
<th class="pge__th">Countdown (s)</th>
|
||||
<th class="pge__th">Daily limit</th>
|
||||
<th class="pge__th pge__th--action" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="group in groups"
|
||||
:key="group.name"
|
||||
class="border-b border-neutral-800/50"
|
||||
class="pge__tr"
|
||||
>
|
||||
<td class="py-3 px-4 text-neutral-200 font-medium">{{ group.name }}</td>
|
||||
<td class="py-3 px-4">
|
||||
<td class="pge__td pge__td--name">{{ group.name }}</td>
|
||||
<td class="pge__td pge__td--num">
|
||||
<input
|
||||
type="number"
|
||||
class="pge__num-input"
|
||||
:value="group.homesLimit"
|
||||
min="0"
|
||||
@input="updateField(group.name, 'homesLimit', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<td class="pge__td pge__td--num">
|
||||
<input
|
||||
type="number"
|
||||
class="pge__num-input"
|
||||
:value="group.cooldown"
|
||||
min="0"
|
||||
@input="updateField(group.name, 'cooldown', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<td class="pge__td pge__td--num">
|
||||
<input
|
||||
type="number"
|
||||
class="pge__num-input"
|
||||
:value="group.countdown"
|
||||
@input="updateField(group.name, 'countdown', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
@input="updateField(group.name, 'countdown', Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<td class="pge__td pge__td--num">
|
||||
<input
|
||||
type="number"
|
||||
class="pge__num-input"
|
||||
:value="group.dailyLimit"
|
||||
@input="updateField(group.name, 'dailyLimit', Number(($event.target as HTMLInputElement).value))"
|
||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||
min="0"
|
||||
@input="updateField(group.name, 'dailyLimit', Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<button
|
||||
@click="removeGroup(group.name)"
|
||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
<td class="pge__td pge__td--action">
|
||||
<button class="pge__del" type="button" @click="removeGroup(group.name)">
|
||||
<Icon name="trash-2" :size="15" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -193,3 +193,63 @@ function updateField(groupName: string, field: string, value: number) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ---------- Shell ---------- */
|
||||
.pge { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
/* ---------- Head ---------- */
|
||||
.pge__head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.pge__section-label {
|
||||
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
|
||||
/* ---------- Add row ---------- */
|
||||
.pge__add-row { display: flex; gap: 8px; align-items: center; }
|
||||
.pge__name-input {
|
||||
flex: 1; height: var(--control-h-sm); padding: 0 10px;
|
||||
background: var(--surface-inset); border: 0; border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default); font-family: var(--font-sans);
|
||||
font-size: var(--text-sm); color: var(--text-primary);
|
||||
}
|
||||
.pge__name-input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
.pge__name-input::placeholder { color: var(--text-muted); }
|
||||
|
||||
/* ---------- Table ---------- */
|
||||
.pge__table-wrap { border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--ring-default); }
|
||||
.pge__table { width: 100%; border-collapse: collapse; }
|
||||
.pge__th {
|
||||
padding: 9px 12px; font-size: var(--text-xs); font-weight: 600;
|
||||
color: var(--text-tertiary); text-align: center;
|
||||
background: var(--surface-raised); border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.pge__th--left { text-align: left; }
|
||||
.pge__th--action { width: 44px; }
|
||||
.pge__tr { border-bottom: 1px solid var(--border-subtle); }
|
||||
.pge__tr:last-child { border-bottom: 0; }
|
||||
.pge__tr:hover { background: var(--surface-hover); }
|
||||
.pge__td { padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.pge__td--name { font-weight: 500; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.pge__td--num { text-align: center; }
|
||||
.pge__td--action { text-align: center; }
|
||||
|
||||
/* ---------- Number input (table cell) ---------- */
|
||||
.pge__num-input {
|
||||
width: 80px; height: 28px; padding: 0 8px; text-align: center;
|
||||
background: var(--surface-inset); border: 0; border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default); font-family: var(--font-mono);
|
||||
font-size: var(--text-sm); color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pge__num-input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
|
||||
/* ---------- Delete button ---------- */
|
||||
.pge__del {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-sm); border: none;
|
||||
background: transparent; color: var(--text-muted); cursor: pointer;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.pge__del:hover { color: var(--danger); background: var(--status-offline-soft); }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
warps: Record<string, { x: number; y: number; z: number }>
|
||||
@@ -28,49 +30,139 @@ function removeWarp(name: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Warps</h3>
|
||||
<div class="warp-editor">
|
||||
<div class="warp-editor__label">Warps</div>
|
||||
|
||||
<!-- Add Warp -->
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
<!-- Add warp row -->
|
||||
<div class="warp-editor__add">
|
||||
<Input
|
||||
v-model="newWarpName"
|
||||
placeholder="Warp name..."
|
||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
||||
:mono="true"
|
||||
style="flex: 1"
|
||||
@keydown.enter="addWarp"
|
||||
/>
|
||||
<button
|
||||
@click="addWarp"
|
||||
<Button
|
||||
size="sm"
|
||||
icon="plus"
|
||||
:disabled="!newWarpName.trim()"
|
||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add
|
||||
</button>
|
||||
@click="addWarp"
|
||||
>Add</Button>
|
||||
</div>
|
||||
|
||||
<!-- Warp List -->
|
||||
<div v-if="Object.keys(warps).length === 0" class="text-neutral-500 text-sm text-center py-4">
|
||||
<!-- Empty state -->
|
||||
<div v-if="Object.keys(warps).length === 0" class="warp-editor__empty">
|
||||
No warps defined. Add warps here and set coordinates in-game.
|
||||
</div>
|
||||
|
||||
<!-- Warp list -->
|
||||
<div
|
||||
v-for="(coords, name) in warps"
|
||||
:key="name"
|
||||
class="flex items-center justify-between bg-neutral-800/50 border border-neutral-700/50 rounded-lg px-4 py-3"
|
||||
class="warp-row"
|
||||
>
|
||||
<div>
|
||||
<span class="text-neutral-200 font-medium">{{ name }}</span>
|
||||
<span class="text-neutral-500 text-xs ml-3">
|
||||
{{ coords.x.toFixed(1) }}, {{ coords.y.toFixed(1) }}, {{ coords.z.toFixed(1) }}
|
||||
<div class="warp-row__id">
|
||||
<span class="warp-row__name">{{ name }}</span>
|
||||
<span class="warp-row__coords">
|
||||
{{ (coords as { x: number; y: number; z: number }).x.toFixed(1) }},
|
||||
{{ (coords as { x: number; y: number; z: number }).y.toFixed(1) }},
|
||||
{{ (coords as { x: number; y: number; z: number }).z.toFixed(1) }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="warp-row__remove"
|
||||
type="button"
|
||||
:aria-label="`Remove warp ${name}`"
|
||||
@click="removeWarp(name as string)"
|
||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
<Icon name="trash-2" :size="14" :stroke-width="2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.warp-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.warp-editor__label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.warp-editor__add {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.warp-editor__empty {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.warp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--ring-default);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.warp-row__id {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.warp-row__name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.warp-row__coords {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.warp-row__remove {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
transition: background var(--dur-fast) var(--ease-standard),
|
||||
color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.warp-row__remove:hover {
|
||||
background: var(--status-offline-soft);
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
.warp-row__remove:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
</style>
|
||||
|
||||
114
frontend/src/composables/useThemeGame.ts
Normal file
114
frontend/src/composables/useThemeGame.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* useThemeGame — the Corrosion design-system theming contract.
|
||||
*
|
||||
* Drives `data-theme` and `data-game` on <html>, the two attributes the token
|
||||
* system keys off (see styles/tokens/colors.css + game-themes.css):
|
||||
* <html data-theme="dark|light" data-game="rust|dune|conan|soulmask|...">
|
||||
*
|
||||
* Dark is primary; Rust (Oxide Orange) is the default/brand accent.
|
||||
*
|
||||
* Runtime swaps add the `cc-skin-swap` class for one frame so every
|
||||
* accent-consuming surface repaints immediately — without it Chrome leaves
|
||||
* elements that read var(--accent) AND have a color/bg transition on the old
|
||||
* accent until the next reflow (see styles/tokens/base.css + readme).
|
||||
*/
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export type Theme = 'dark' | 'light'
|
||||
export type Game =
|
||||
| 'rust'
|
||||
| 'dune'
|
||||
| 'conan'
|
||||
| 'soulmask'
|
||||
| 'ark'
|
||||
| 'valheim'
|
||||
| 'palworld'
|
||||
|
||||
/** The fleet filter: 'all' (every game) plus each individual game. */
|
||||
export type ActiveGame = 'all' | Game
|
||||
|
||||
const THEME_KEY = 'cc-theme'
|
||||
const GAME_KEY = 'cc-game'
|
||||
const ACTIVE_GAME_KEY = 'cc-active-game'
|
||||
|
||||
const VALID_THEMES: readonly Theme[] = ['dark', 'light']
|
||||
const VALID_GAMES: readonly Game[] = [
|
||||
'rust',
|
||||
'dune',
|
||||
'conan',
|
||||
'soulmask',
|
||||
'ark',
|
||||
'valheim',
|
||||
'palworld',
|
||||
]
|
||||
|
||||
// Module-scope singletons so every caller shares one reactive source.
|
||||
const theme = ref<Theme>('dark')
|
||||
const game = ref<Game>('rust')
|
||||
// Fleet filter: 'all' shows every game and uses the neutral house skin (Oxide);
|
||||
// a specific game both filters the fleet AND re-skins the shell (the drill-in rule).
|
||||
const activeGame = ref<ActiveGame>('all')
|
||||
|
||||
function apply(): void {
|
||||
const el = document.documentElement
|
||||
el.classList.add('cc-skin-swap')
|
||||
el.setAttribute('data-theme', theme.value)
|
||||
el.setAttribute('data-game', game.value)
|
||||
// Keep Tailwind's `dark` class in sync — existing views may use `dark:` utilities.
|
||||
el.classList.toggle('dark', theme.value === 'dark')
|
||||
// Drop the swap guard after the paint that picked up the new accent.
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => el.classList.remove('cc-skin-swap')),
|
||||
)
|
||||
}
|
||||
|
||||
let initialized = false
|
||||
|
||||
/**
|
||||
* Read persisted prefs and apply them to <html>. Call once at app start
|
||||
* (after a tiny inline FOUC guard in index.html has set the initial attrs).
|
||||
*/
|
||||
export function initThemeGame(): void {
|
||||
if (initialized) return
|
||||
const t = localStorage.getItem(THEME_KEY)
|
||||
if (t && (VALID_THEMES as string[]).includes(t)) theme.value = t as Theme
|
||||
const ag = localStorage.getItem(ACTIVE_GAME_KEY)
|
||||
if (ag && (ag === 'all' || (VALID_GAMES as string[]).includes(ag))) {
|
||||
activeGame.value = ag as ActiveGame
|
||||
}
|
||||
// Skin follows the filter: 'all' -> neutral house (rust/oxide), else the game.
|
||||
game.value = activeGame.value === 'all' ? 'rust' : activeGame.value
|
||||
apply()
|
||||
initialized = true
|
||||
}
|
||||
|
||||
export function useThemeGame() {
|
||||
function setTheme(t: Theme): void {
|
||||
theme.value = t
|
||||
localStorage.setItem(THEME_KEY, t)
|
||||
apply()
|
||||
}
|
||||
function setGame(g: Game): void {
|
||||
game.value = g
|
||||
localStorage.setItem(GAME_KEY, g)
|
||||
apply()
|
||||
}
|
||||
function setActiveGame(g: ActiveGame): void {
|
||||
activeGame.value = g
|
||||
localStorage.setItem(ACTIVE_GAME_KEY, g)
|
||||
// 'all' uses the neutral house skin (rust/oxide); a game re-skins to itself.
|
||||
setGame(g === 'all' ? 'rust' : g)
|
||||
}
|
||||
function toggleTheme(): void {
|
||||
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
return {
|
||||
theme: readonly(theme),
|
||||
game: readonly(game),
|
||||
activeGame: readonly(activeGame),
|
||||
setTheme,
|
||||
setGame,
|
||||
setActiveGame,
|
||||
toggleTheme,
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import { VueFinderPlugin } from 'vuefinder'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { initThemeGame } from './composables/useThemeGame'
|
||||
import './style.css'
|
||||
import 'vuefinder/dist/vuefinder.css'
|
||||
|
||||
@@ -17,4 +18,7 @@ app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(VueFinderPlugin)
|
||||
|
||||
// Apply the design-system theming contract (data-theme/data-game on <html>).
|
||||
initThemeGame()
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -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',
|
||||
|
||||
145
frontend/src/stores/autodoors.ts
Normal file
145
frontend/src/stores/autodoors.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
145
frontend/src/stores/betterchat.ts
Normal file
145
frontend/src/stores/betterchat.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
145
frontend/src/stores/furnacesplitter.ts
Normal file
145
frontend/src/stores/furnacesplitter.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user