12 Commits
25 changed files with 1018 additions and 199 deletions
@@ -36,10 +36,10 @@ public class SecurityConfig {
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login/userLogin/**", "/login/requestNewUserLogin/**").permitAll()
.requestMatchers("/team/**", "/history/**").permitAll()
.requestMatchers("/form/**").hasAuthority(PermissionClaimDto.USER.getValue())
.requestMatchers("/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
// .requestMatchers("/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.anyRequest().permitAll()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
@@ -83,8 +83,6 @@ public class LoginController implements LoginApi {
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login")
@Override
public ResponseEntity<String> login(String code) {
CacheEntry cacheEntry1 = new CacheEntry(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"), Instant.now().plusSeconds(TimeUnit.DAYS.toSeconds(1)));
cache.put("23232323", cacheEntry1);
if (code == null) {
return ResponseEntity.badRequest().build();
}
@@ -134,12 +132,12 @@ public class LoginController implements LoginApi {
Instant now = Instant.now();
//TODO make a JWT for renewing and one for storing permissions for a session (expiry 1 hour)
Instant expiryTime = now.plusSeconds(TimeUnit.DAYS.toSeconds(30));
CompletableFuture<PrivilegedUser> privilegedUserCompletableFuture = new CompletableFuture<>();
CompletableFuture<Optional<PrivilegedUser>> privilegedUserCompletableFuture = new CompletableFuture<>();
List<PermissionClaimDto> claimList = new ArrayList<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
try {
PrivilegedUser privilegedUser = sqlSession.getMapper(PrivilegedUserMapper.class)
Optional<PrivilegedUser> privilegedUser = sqlSession.getMapper(PrivilegedUserMapper.class)
.getUserByUuid(uuid.toString());
privilegedUserCompletableFuture.complete(privilegedUser);
@@ -148,17 +146,15 @@ public class LoginController implements LoginApi {
privilegedUserCompletableFuture.completeExceptionally(e);
}
});
PrivilegedUser privilegedUser = privilegedUserCompletableFuture.join();
Optional<PrivilegedUser> privilegedUser = privilegedUserCompletableFuture.join();
claimList.add(PermissionClaimDto.USER);
if (privilegedUser != null) {
privilegedUser.getPermissions().forEach(permission -> {
try {
claimList.add(PermissionClaimDto.valueOf(permission));
} catch (IllegalArgumentException e) {
log.warn("Received invalid permission claim: {}", permission);
}
});
}
privilegedUser.ifPresent(user -> user.getPermissions().forEach(permission -> {
try {
claimList.add(PermissionClaimDto.valueOf(permission));
} catch (IllegalArgumentException e) {
log.warn("Received invalid permission claim: {}", permission);
}
}));
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("altitudeweb")
.claim("authorities", claimList.stream().map(PermissionClaimDto::getValue).toList())
@@ -0,0 +1,176 @@
package com.alttd.altitudeweb.controllers.particles;
import com.alttd.altitudeweb.api.ParticlesApi;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@Slf4j
@RequiredArgsConstructor
@RestController
public class ParticleController implements ParticlesApi {
@Value("${login.secret:#{null}}")
private String loginSecret;
@Value("${particles.file_path}")
private String particlesFilePath;
@Value("${notification.server.url:http://localhost:8080}")
private String notificationServerUrl;
@Override
public ResponseEntity<Resource> downloadFile(String authorization, String filename) throws Exception {
if (authorization == null || !authorization.equals(loginSecret)) {
return ResponseEntity.status(401).build();
}
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not downloading particles file", particlesFilePath);
return ResponseEntity.status(404).build();
}
File targetFile = new File(file, filename);
return getFileForDownload(targetFile, filename);
}
@Override
public ResponseEntity<Resource> downloadFileForUser(String authorization, String uuid, String filename) throws Exception {
if (authorization == null || !authorization.equals(loginSecret)) {
return ResponseEntity.status(401).build();
}
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not downloading particles user file", particlesFilePath);
return ResponseEntity.status(404).build();
}
File targetDir = new File(file, uuid);
if (targetDir.exists()) {
return getFileForDownload(targetDir, filename);
} else {
log.warn("User {} does not have a directory for particles files", uuid);
return ResponseEntity.notFound().build();
}
}
private ResponseEntity<Resource> getFileForDownload(File file, String filename) {
File targetFile = new File(file, filename);
if (!targetFile.exists()) {
log.warn("Particles file {} does not exist", targetFile.getAbsolutePath());
return ResponseEntity.notFound().build();
}
if (!targetFile.isFile()) {
log.warn("Particles file {} is not a file", targetFile.getAbsolutePath());
return ResponseEntity.status(404).build();
}
try {
Path path = targetFile.toPath();
ByteArrayResource resource = new ByteArrayResource(Files.readAllBytes(path));
return ResponseEntity.ok()
.contentLength(targetFile.length())
.contentType(MediaType.APPLICATION_JSON)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.body(resource);
} catch (IOException e) {
log.error("Failed to read particles file {}: {}", targetFile.getAbsolutePath(), e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@Override
public ResponseEntity<Void> saveFile(String filename, MultipartFile content) throws Exception {
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not saving particles file", particlesFilePath);
return ResponseEntity.status(404).build();
}
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
notifyServerOfFileUpload(filename);
return voidResponseEntity;
}
@Override
public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) throws Exception {
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not saving particles user file", particlesFilePath);
return ResponseEntity.status(404).build();
}
File targetDir = new File(file, uuid);
if (!file.exists()) {
log.debug("Creating particles directory {}", targetDir.getAbsolutePath());
if (targetDir.mkdirs()) {
log.info("Created particles user directory {}", targetDir.getAbsolutePath());
}
}
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
notifyServerOfFileUpload(uuid, filename);
return voidResponseEntity;
}
private void notifyServerOfFileUpload(String filename) {
String notificationUrl = String.format("%s/notify/%s.json", notificationServerUrl, filename);
sendNotification(notificationUrl, String.format("file upload: %s", filename));
}
private void notifyServerOfFileUpload(String uuid, String filename) {
String notificationUrl = String.format("%s/notify/%s/%s.json", notificationServerUrl, uuid, filename);
sendNotification(notificationUrl, String.format("file upload for user %s: %s", uuid, filename));
}
private void sendNotification(String notificationUrl, String logDescription) {
try {
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> response = restTemplate.getForEntity(notificationUrl, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
log.info("Successfully notified server of {}", logDescription);
} else {
log.warn("Failed to notify server of {}, status: {}",
logDescription, response.getStatusCode());
}
} catch (Exception e) {
log.error("Error notifying server of {}", logDescription, e);
}
}
private ResponseEntity<Void> writeContentToFile(File dir, String filename, MultipartFile content) {
File targetFile = new File(dir, filename);
if (!Files.isWritable(targetFile.toPath())) {
log.error("Particles file {} is not writable", targetFile.getAbsolutePath());
return ResponseEntity.status(403).build();
}
if (targetFile.exists()) {
log.warn("Overwriting existing particles file {}", targetFile.getAbsolutePath());
}
try {
content.transferTo(targetFile);
} catch (Exception e) {
log.error("Failed to write particles file {}", targetFile.getAbsolutePath(), e);
return ResponseEntity.status(500).build();
}
return ResponseEntity.ok().build();
}
}
@@ -6,4 +6,6 @@ database.user=${DB_USER:root}
database.password=${DB_PASSWORD:root}
cors.allowed-origins=${CORS:https://alttd.com}
login.secret=${LOGIN_SECRET:SET_TOKEN}
particles.file_path=${user.home}/.altitudeweb/particles
notification.server.url=${SERVER_IP:10.0.0.107}:${SERVER_PORT:8080}
logging.level.com.alttd.altitudeweb=INFO
@@ -1,8 +1,10 @@
package com.alttd.altitudeweb.database.web_db;
import org.apache.ibatis.annotations.*;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Optional;
public interface PrivilegedUserMapper {
@@ -23,7 +25,7 @@ public interface PrivilegedUserMapper {
@Result(property = "permissions", column = "id", javaType = List.class,
many = @Many(select = "getPermissionsForUser"))
})
PrivilegedUser getUserByUuid(@Param("uuid") String uuid);
Optional<PrivilegedUser> getUserByUuid(@Param("uuid") String uuid);
/**
* Retrieves all privileged users with their permissions
+2 -2
View File
@@ -110,7 +110,7 @@ export const routes: Routes = [
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
},
{
path: 'particles',
loadComponent: () => import('./particles/particles.component').then(m => m.ParticlesComponent)
path: 'login',
loadComponent: () => import('./login/login.component').then(m => m.LoginDialogComponent)
},
];
@@ -1,41 +1,48 @@
<mat-card>
<mat-card-header>
<mat-card-title>Frames</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="frames-container">
<mat-tab-group [selectedIndex]="frames.indexOf(currentFrame)"
(selectedIndexChange)="switchFrame(frames[$event])">
<mat-tab *ngFor="let frameId of frames" [label]="frameId">
<div class="frame-content">
<h3>Particles in {{ frameId }}</h3>
<div class="particles-list">
<div *ngFor="let particle of particleData.frames[frameId]; let i = index" class="particle-item">
<span>Particle {{ i + 1 }}: ({{ particle.x.toFixed(2) }}, {{ particle.y.toFixed(2) }}
, {{ particle.z.toFixed(2) }})</span>
<button mat-icon-button color="warn" (click)="removeParticle(frameId, i)">
<mat-icon>delete</mat-icon>
<div class="card-div">
<mat-card>
<mat-card-header>
<mat-card-title>Frames</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="frames-container">
<mat-tab-group [selectedIndex]="frames.indexOf(currentFrame)"
(selectedIndexChange)="switchFrame(frames[$event])">
<mat-tab *ngFor="let frameId of frames" [label]="frameId">
<div class="frame-content">
<h3>Particles in {{ frameId }}</h3>
<div class="particles-list">
<div *ngFor="let particle of particleData.frames[frameId]; let i = index" class="particle-item">
<span class="particle-item-text">
Particle {{ i + 1 }}: ({{ particle.x.toFixed(2) }}, {{ particle.y.toFixed(2) }}
, {{ particle.z.toFixed(2) }})
</span>
<button mat-icon-button (click)="removeParticle(frameId, i)">
<mat-icon>delete</mat-icon>
</button>
<button mat-icon-button (click)="highlightParticle(frameId, i)">
<mat-icon>lightbulb</mat-icon>
</button>
</div>
<div *ngIf="!particleData.frames[frameId] || particleData.frames[frameId].length === 0"
class="no-particles">
No particles in this frame. Click on the plane to add particles.
</div>
</div>
<div class="frame-actions">
<button mat-raised-button color="warn" (click)="removeFrame(frameId)"
[disabled]="frames.length <= 1">
Remove Frame
</button>
</div>
<div *ngIf="!particleData.frames[frameId] || particleData.frames[frameId].length === 0"
class="no-particles">
No particles in this frame. Click on the plane to add particles.
</div>
</div>
<div class="frame-actions">
<button mat-raised-button color="warn" (click)="removeFrame(frameId)"
[disabled]="frames.length <= 1">
Remove Frame
</button>
</div>
</div>
</mat-tab>
</mat-tab-group>
<div class="add-frame">
<button mat-raised-button color="primary" (click)="addFrame()">
Add New Frame
</button>
</mat-tab>
</mat-tab-group>
<div class="add-frame">
<button mat-raised-button color="primary" (click)="addFrame()">
Add New Frame
</button>
</div>
</div>
</div>
</mat-card-content>
</mat-card>
</mat-card-content>
</mat-card>
</div>
@@ -17,10 +17,14 @@
.particle-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid #eee;
gap: 10px;
}
.particle-item-text {
flex-grow: 1;
}
.particle-item:last-child {
@@ -30,7 +34,7 @@
.no-particles {
padding: 20px;
text-align: center;
color: #888;
color: var(--color-primairy);
}
.frame-actions {
@@ -44,3 +48,14 @@
display: flex;
justify-content: center;
}
.card-div {
mat-card {
background-color: var(--color-primary);
color: var(--font-color);
}
}
span {
color: var(--font-color);
}
@@ -83,4 +83,7 @@ export class FramesComponent {
}
public highlightParticle(frameId: string, i: number) {
this.particleManagerService.highlightParticle(frameId, i);
}
}
@@ -1,11 +1,37 @@
<mat-card class="color-picker-card">
<mat-card-header>
<mat-card-title>Particle Color</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="color-picker">
<input type="color" [(ngModel)]="selectedColor">
<span>Selected Color: {{ selectedColor }}</span>
</div>
</mat-card-content>
</mat-card>
<div class="card-div">
<mat-card class="particle-card">
<mat-card-header>
<mat-card-title>Particle Properties</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="particle-properties">
<div class="property-row">
<div class="color-picker">
<input type="color" [(ngModel)]="selectedColor">
<span>Current color: {{ selectedColor }}</span>
</div>
<mat-form-field appearance="fill" class="type-field">
<mat-label>Select Particle Type</mat-label>
<input type="text"
placeholder="Search for a particle type"
matInput
[formControl]="particleTypeControl"
[matAutocomplete]="auto">
<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn">
<mat-option *ngFor="let type of filteredParticleTypes | async" [value]="type">
{{ type }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</div>
<div class="size-slider">
<mat-slider min="0.1" max="4" step="0.1" class="full-width">
<input matSliderThumb [(ngModel)]="selectedSize">
</mat-slider>
<span>Size: {{ selectedSize }}</span>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
@@ -1,17 +1,61 @@
.color-picker-card {
.particle-card {
margin-top: 20px;
margin-bottom: 20px;
}
.particle-properties {
display: flex;
flex-direction: column;
gap: 20px;
}
.property-row {
display: flex;
align-items: center;
gap: 15px;
width: 100%;
}
.type-field {
flex: 1;
min-width: 20ch;
max-width: 40ch;
}
.color-picker {
display: flex;
flex: 1;
align-items: center;
gap: 15px;
gap: 10px;
}
.color-picker input[type="color"] {
width: 50px;
height: 50px;
width: 40px;
height: 40px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.card-div {
mat-card {
background-color: var(--color-primary);
color: var(--font-color);
}
}
.full-width {
width: 100%;
}
.size-slider {
width: 95%;
display: flex;
flex-direction: column;
margin-top: 5px;
}
span {
color: var(--font-color);
font-size: 0.9rem;
}
@@ -1,7 +1,16 @@
import {Component} from '@angular/core';
import {Component, OnInit} from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {ParticleManagerService} from '../../services/particle-manager.service';
import {Particle} from '../../models/particle.model';
import {MatSliderModule} from '@angular/material/slider';
import {MatSelectModule} from '@angular/material/select';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {MatAutocompleteModule} from '@angular/material/autocomplete';
import {Observable} from 'rxjs';
import {map, startWith} from 'rxjs/operators';
import {AsyncPipe, NgForOf} from '@angular/common';
@Component({
selector: 'app-particle',
@@ -11,16 +20,55 @@ import {ParticleManagerService} from '../../services/particle-manager.service';
MatCardHeader,
MatCardTitle,
ReactiveFormsModule,
FormsModule
FormsModule,
MatSliderModule,
MatSelectModule,
MatFormFieldModule,
MatInputModule,
MatAutocompleteModule,
NgForOf,
AsyncPipe
],
templateUrl: './particle.component.html',
styleUrl: './particle.component.scss'
})
export class ParticleComponent {
export class ParticleComponent implements OnInit {
// Available particle types from the enum
particleTypes = Object.values(Particle);
// Form control for the particle type dropdown with filtering
particleTypeControl = new FormControl();
filteredParticleTypes: Observable<string[]>;
constructor(
private particleManagerService: ParticleManagerService,
) {
this.filteredParticleTypes = this.particleTypeControl.valueChanges.pipe(
startWith(''),
map(value => this._filterParticleTypes(value || ''))
);
}
ngOnInit() {
// Initialize the particle type control with the current value
this.particleTypeControl.setValue(this.selectedParticle);
// Update the selected particle when the control value changes
this.particleTypeControl.valueChanges.subscribe(value => {
if (value && Object.values(Particle).includes(value)) {
this.selectedParticle = value;
}
});
}
private _filterParticleTypes(value: string): string[] {
const filterValue = value.toLowerCase();
return this.particleTypes.filter(type => type.toLowerCase().includes(filterValue));
}
// Display function for the autocomplete
displayFn(particle: string): string {
return particle ? particle : '';
}
/**
@@ -37,4 +85,31 @@ export class ParticleComponent {
this.particleManagerService.setSelectedColor(color);
}
/**
* Get the selected particle type
*/
public get selectedParticle(): Particle {
return this.particleManagerService.particle;
}
/**
* Set the selected particle type
*/
public set selectedParticle(particle: Particle) {
this.particleManagerService.particle = particle;
}
/**
* Get the selected particle size
*/
public get selectedSize(): number {
return this.particleManagerService.size;
}
/**
* Set the selected particle size
*/
public set selectedSize(size: number) {
this.particleManagerService.size = size;
}
}
@@ -1,91 +1,93 @@
<mat-card>
<mat-card-header>
<mat-card-title>Particle Properties</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Particle Name</mat-label>
<input matInput [(ngModel)]="particleData.particle_name" placeholder="Enter particle name">
</mat-form-field>
</div>
<div class="card-div">
<mat-card>
<mat-card-header>
<mat-card-title>Particle Properties</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Particle Name</mat-label>
<input matInput [(ngModel)]="particleData.particle_name" placeholder="Enter particle name">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Display Name</mat-label>
<input matInput [(ngModel)]="particleData.display_name" placeholder="Enter display name">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Display Name</mat-label>
<input matInput [(ngModel)]="particleData.display_name" placeholder="Enter display name">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Particle Type</mat-label>
<mat-select [(ngModel)]="particleData.particle_type">
<mat-option *ngFor="let type of particleTypes" [value]="type">{{ type }}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Particle Type</mat-label>
<mat-select [(ngModel)]="particleData.particle_type">
<mat-option *ngFor="let type of particleTypes" [value]="type">{{ type }}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Lore</mat-label>
<textarea matInput [(ngModel)]="particleData.lore" placeholder="Enter lore"></textarea>
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Lore</mat-label>
<textarea matInput [(ngModel)]="particleData.lore" placeholder="Enter lore"></textarea>
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Display Item</mat-label>
<input matInput [(ngModel)]="particleData.display_item" placeholder="Enter display item">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Display Item</mat-label>
<input matInput [(ngModel)]="particleData.display_item" placeholder="Enter display item">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Permission</mat-label>
<input matInput [(ngModel)]="particleData.permission" placeholder="Enter permission">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Permission</mat-label>
<input matInput [(ngModel)]="particleData.permission" placeholder="Enter permission">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Package Permission</mat-label>
<input matInput [(ngModel)]="particleData.package_permission" placeholder="Enter package permission">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Package Permission</mat-label>
<input matInput [(ngModel)]="particleData.package_permission" placeholder="Enter package permission">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Frame Delay</mat-label>
<input matInput type="number" [(ngModel)]="particleData.frame_delay" placeholder="Enter frame delay">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Frame Delay</mat-label>
<input matInput type="number" [(ngModel)]="particleData.frame_delay" placeholder="Enter frame delay">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Repeat</mat-label>
<input matInput type="number" [(ngModel)]="particleData.repeat" placeholder="Enter repeat count">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Repeat</mat-label>
<input matInput type="number" [(ngModel)]="particleData.repeat" placeholder="Enter repeat count">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Repeat Delay</mat-label>
<input matInput type="number" [(ngModel)]="particleData.repeat_delay"
placeholder="Enter repeat delay">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Repeat Delay</mat-label>
<input matInput type="number" [(ngModel)]="particleData.repeat_delay"
placeholder="Enter repeat delay">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Random Offset</mat-label>
<input matInput type="number" [(ngModel)]="particleData.random_offset"
placeholder="Enter random offset">
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Random Offset</mat-label>
<input matInput type="number" [(ngModel)]="particleData.random_offset"
placeholder="Enter random offset">
</mat-form-field>
</div>
<div class="form-row">
<mat-checkbox [(ngModel)]="particleData.stationary">Stationary</mat-checkbox>
</div>
</mat-card-content>
</mat-card>
<div class="form-row">
<mat-checkbox [(ngModel)]="particleData.stationary"><span>Stationary</span></mat-checkbox>
</div>
</mat-card-content>
</mat-card>
</div>
@@ -0,0 +1,14 @@
.form-row {
margin-bottom: 15px;
}
.card-div {
mat-card {
background-color: var(--color-primary);
color: var(--font-color);
}
}
span {
color: var(--font-color);
}
@@ -1,9 +1,22 @@
<div #rendererContainer class="renderer-container">
<div class="plane-controls-overlay">
<button mat-mini-fab color="primary" (click)="togglePlaneLock()"
[matTooltip]="isPlaneLocked ? 'Unlock Plane' : 'Lock Plane'">
<mat-icon>{{ isPlaneLocked ? 'lock' : 'lock_open' }}</mat-icon>
</button>
<div>
<mat-form-field appearance="outline" style="width: 10ch">
<mat-label>Opacity</mat-label>
<input matInput type="number" [(ngModel)]="opacity" min="0" max="1" step="0.01" placeholder="">
</mat-form-field>
</div>
<div class="button-row">
<button mat-mini-fab color="primary" (click)="resetCamera()"
matTooltip="Reset camera">
<mat-icon>location_searching</mat-icon>
</button>
<button mat-mini-fab color="primary" (click)="togglePlaneLock()"
[matTooltip]="isPlaneLocked ? 'Unlock Plane' : 'Lock Plane'">
<mat-icon>{{ isPlaneLocked ? 'lock' : 'lock_open' }}</mat-icon>
</button>
</div>
<div *ngIf="isPlaneLocked" class="plane-orientation-buttons">
<button mat-mini-fab color="warn" (click)="setPlaneOrientation(planeOrientations.VERTICAL_ABOVE)"
@@ -3,7 +3,7 @@
border: 1px solid #ccc;
border-radius: 4px;
overflow: hidden;
background-color: #f0f0f0;
background-color: var(--color-primairy);
display: flex;
justify-content: center;
align-items: center;
@@ -21,6 +21,13 @@
z-index: 10;
}
.button-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 10px;
}
.plane-orientation-buttons {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -42,5 +49,5 @@
.plane-orientation-buttons button.active {
opacity: 1;
transform: scale(1.1);
box-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
box-shadow: 0 0 10px var(--color-tertiary);
}
@@ -7,6 +7,8 @@ import {MatTooltip} from '@angular/material/tooltip';
import {RendererService} from '../../services/renderer.service';
import {PlayerModelService} from '../../services/player-model.service';
import {InputHandlerService} from '../../services/input-handler.service';
import {FormsModule} from '@angular/forms';
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
@Component({
selector: 'app-render-container',
@@ -14,7 +16,11 @@ import {InputHandlerService} from '../../services/input-handler.service';
MatIcon,
MatMiniFabButton,
MatTooltip,
NgIf
NgIf,
FormsModule,
MatInput,
MatFormField,
MatLabel
],
templateUrl: './render-container.component.html',
styleUrl: './render-container.component.scss'
@@ -74,6 +80,14 @@ export class RenderContainerComponent implements AfterViewInit, OnDestroy {
return this.intersectionPlaneService.isPlaneLocked();
}
public set opacity(opacity: number) {
this.intersectionPlaneService.currentOpacity = opacity;
}
public get opacity(): number {
return this.intersectionPlaneService.currentOpacity;
}
/**
* Toggle the plane locked state
*/
@@ -82,6 +96,10 @@ export class RenderContainerComponent implements AfterViewInit, OnDestroy {
this.intersectionPlaneService.setPlaneLocked(newLockedState);
}
public resetCamera(): void {
this.rendererService.resetCamera();
}
/**
* Get the current plane orientation
*/
@@ -1,21 +1,35 @@
/**
* Defines the types of particles available in the system
*/
export enum ParticleType {
REDSTONE = 'REDSTONE',
export enum Particle {
DUST = 'DUST',
DUST_COLOR_TRANSITION = 'DUST_COLOR_TRANSITION',
TINTED_LEAVES = 'TINTED_LEAVES'
// Other particle types can be added later
}
export enum ParticleType {
HEAD = 'HEAD',
TRAIL = 'TRAIL',
BREAK_PLACE_BLOCK = 'BREAK_PLACE_BLOCK',
DEATH = 'DEATH',
KILL = 'KILL',
CLICK_BLOCK = 'CLICK_BLOCK',
TELEPORT_ARRIVE = 'TELEPORT',
}
/**
* Represents a single particle's information
*/
export interface ParticleInfo {
particle_type: string;
particle_type: Particle;
x: number;
y: number;
z: number;
color: string;
extra: number;
extra?: number;
color?: string;
color_gradient_end?: string;
size?: number;
}
/**
@@ -16,10 +16,14 @@
gap: 20px;
}
label, span {
color: var(--font-color);
}
.plane-controls {
margin-top: 10px;
padding: 10px;
background-color: #f5f5f5;
background-color: var(--color-primairy);
border-radius: 4px;
display: flex;
align-items: center;
@@ -30,10 +34,92 @@
flex: 1;
}
.form-row {
margin-bottom: 15px;
:host ::ng-deep {
.mdc-text-field--outlined {
background-color: var(--color-primary);
}
.mdc-text-field--outlined .mdc-floating-label,
.mdc-text-field--outlined .mdc-text-field__input,
.mat-mdc-form-field-label,
.mat-mdc-select-value-text,
.mat-mdc-select-arrow,
.mat-mdc-checkbox-label,
.mat-mdc-card-header,
.mat-mdc-card-title,
.mat-mdc-card-content {
color: var(--font-color) !important;
}
.mdc-text-field--outlined .mdc-notched-outline .mdc-notched-outline__leading,
.mdc-text-field--outlined .mdc-notched-outline .mdc-notched-outline__notch,
.mdc-text-field--outlined .mdc-notched-outline .mdc-notched-outline__trailing {
border-color: var(--font-color) !important;
}
// Fix for dropdown menu
.mat-mdc-select-panel {
background-color: var(--color-primary) !important;
}
.mat-mdc-option {
color: var(--font-color) !important;
}
.mat-mdc-option:hover:not(.mdc-list-item--disabled) {
background-color: rgba(255, 255, 255, 0.1) !important;
}
.mat-mdc-option.mat-mdc-option-active {
background-color: rgba(255, 255, 255, 0.2) !important;
}
.mat-mdc-select-panel {
background-color: var(--color-primary) !important;
}
.mat-mdc-option {
color: var(--font-color) !important;
}
.mat-mdc-option:hover:not(.mdc-list-item--disabled) {
background-color: rgba(255, 255, 255, 0.1) !important;
}
.mat-mdc-option.mat-mdc-option-active {
background-color: rgba(255, 255, 255, 0.2) !important;
}
.mat-mdc-tab {
color: var(--font-color) !important;
}
.mat-mdc-tab-header {
background-color: var(--color-primary);
}
.mat-mdc-tab-label-container {
background-color: var(--color-primary);
}
.mdc-tab__content .mdc-tab__text-label {
color: var(--font-color) !important;
}
.mat-mdc-tab-label, .mat-mdc-tab-link {
color: var(--font-color) !important;
}
.mat-mdc-tab-group.mat-primary .mat-mdc-tab:not(.mat-mdc-tab-disabled) .mdc-tab-indicator__content--underline {
border-color: var(--font-color) !important;
}
.mat-mdc-tab-body-content {
background-color: var(--color-primary);
}
.mat-icon {
color: var(--font-color);
}
}
mat-form-field {
width: 100%;
}
@@ -22,6 +22,7 @@ import {ParticleComponent} from './components/particle/particle.component';
import {FramesComponent} from './components/frames/frames.component';
import {MatSnackBar} from '@angular/material/snack-bar';
import {RenderContainerComponent} from './components/render-container/render-container.component';
import {ParticlesService} from '../../api';
@Component({
selector: 'app-particles',
@@ -55,6 +56,7 @@ export class ParticlesComponent {
private intersectionPlaneService: IntersectionPlaneService,
private particleManagerService: ParticleManagerService,
private matSnackBar: MatSnackBar,
private particlesService: ParticlesService,
) {
}
@@ -93,9 +95,37 @@ export class ParticlesComponent {
return this.particleManagerService.generateJson();
}
public getJsonFile(): Blob {
const jsonContent = this.generateJson();
return new Blob([jsonContent], {type: 'application/json'});
}
public saveJsonToFile(): void {
const jsonContent = this.generateJson();
const blob = new Blob([jsonContent], {type: 'application/json'});
const url = window.URL.createObjectURL(blob);
// Create a temporary link element
const a = document.createElement('a');
a.href = url;
a.download = 'particle-data.json';
// Append to the document, click it, and remove it
document.body.appendChild(a);
a.click();
// Clean up
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
this.matSnackBar.open('JSON file downloaded', '', {duration: 2000});
}
public copyJson() {
navigator.clipboard.writeText(this.generateJson()).then(() => {
this.matSnackBar.open('Copied to clipboard', '', {duration: 2000})
});
//TODO validation
this.particlesService.saveFile(this.particleManagerService.getParticleData().particle_name, this.getJsonFile());
}
}
@@ -25,6 +25,7 @@ export class IntersectionPlaneService {
private planePosition: number = 0; // Position in 1/16th of a block
private currentOrientation: PlaneOrientation = PlaneOrientation.HORIZONTAL_FRONT;
private planeLocked: boolean = false;
private opacity: number = 0.05;
constructor(private rendererService: RendererService) {
}
@@ -152,11 +153,19 @@ export class IntersectionPlaneService {
this.intersectionPlane.material = new THREE.MeshBasicMaterial({
color: color,
transparent: true,
opacity: 0.05,
opacity: this.opacity,
side: THREE.DoubleSide
});
}
public set currentOpacity(opacity: number) {
this.opacity = opacity;
}
public get currentOpacity(): number {
return this.opacity;
}
/**
* Gets the intersection plane
*/
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import {Injectable} from '@angular/core';
import * as THREE from 'three';
import { RendererService } from './renderer.service';
import { ParticleData, ParticleInfo, ParticleType } from '../models/particle.model';
import {RendererService} from './renderer.service';
import {Particle, ParticleData, ParticleInfo, ParticleType} from '../models/particle.model';
/**
* Service responsible for managing particles in the scene
@@ -14,9 +14,9 @@ export class ParticleManagerService {
private particleData: ParticleData = {
particle_name: '',
display_name: '',
particle_type: ParticleType.REDSTONE,
particle_type: ParticleType.TRAIL,
lore: '',
display_item: 'REDSTONE',
display_item: 'DIRT',
permission: '',
package_permission: '',
frame_delay: 1,
@@ -31,15 +31,18 @@ export class ParticleManagerService {
private currentFrame: string = 'frame1';
private frames: string[] = ['frame1'];
private selectedColor: string = '#ff0000';
private selectedParticle: Particle = Particle.DUST;
private selectedSize: number = 1;
constructor(private rendererService: RendererService) {}
constructor(private rendererService: RendererService) {
}
/**
* Adds a particle at the specified position
*/
addParticle(x: number, y: number, z: number): void {
// Create a visual representation of the particle
const particleGeometry = new THREE.SphereGeometry(0.03, 16, 16);
const particleGeometry = new THREE.SphereGeometry(0.03 * this.selectedSize, 16, 16);
const particleMaterial = new THREE.MeshBasicMaterial({color: this.selectedColor});
const particleMesh = new THREE.Mesh(particleGeometry, particleMaterial);
@@ -49,17 +52,17 @@ export class ParticleManagerService {
// Add to particle data
const hexColor = this.selectedColor.replace('#', '');
const r = parseInt(hexColor.substring(0, 2), 16) / 255;
const g = parseInt(hexColor.substring(2, 4), 16) / 255;
const b = parseInt(hexColor.substring(4, 6), 16) / 255;
//TODO make this work for more than just type DUST
const particleInfo: ParticleInfo = {
particle_type: ParticleType.REDSTONE,
particle_type: this.selectedParticle,
x: x,
y: y,
z: z,
color: `${r},${g},${b}`,
extra: 1
color: hexColor,
// color_gradient_end: hexColor2,
extra: 1,
size: this.selectedSize
};
if (!this.particleData.frames[this.currentFrame]) {
@@ -86,16 +89,9 @@ export class ParticleManagerService {
if (!this.particleData.frames[frameId]) return;
for (const particleInfo of this.particleData.frames[frameId]) {
const particleGeometry = new THREE.SphereGeometry(0.03, 16, 16);
// Parse color
const colorParts = particleInfo.color.split(',');
const color = new THREE.Color(
parseFloat(colorParts[0]),
parseFloat(colorParts[1]),
parseFloat(colorParts[2])
);
const particleGeometry = new THREE.SphereGeometry(0.03 * (particleInfo.size ?? 1), 16, 16);
const color = this.getColor(particleInfo);
const particleMaterial = new THREE.MeshBasicMaterial({color});
const particleMesh = new THREE.Mesh(particleGeometry, particleMaterial);
@@ -120,6 +116,57 @@ export class ParticleManagerService {
}
}
highlightParticle(frameId: string, index: number): void {
if (!(this.particleData.frames[frameId] && this.particleData.frames[frameId].length > index)) {
return;
}
const particleInfo = this.particleData.frames[frameId][index];
const color = this.getColor(particleInfo);
const particleMaterial = new THREE.MeshBasicMaterial({color});
const particleGeometry = new THREE.SphereGeometry(0.03 * (particleInfo.size ?? 1), 16, 16);
const particleMesh = new THREE.Mesh(particleGeometry, particleMaterial);
particleMesh.position.set(particleInfo.x, particleInfo.y, particleInfo.z);
this.rendererService.scene.add(particleMesh);
this.particles.push(particleMesh);
this.animatePulse(particleMesh, 3, () => {
this.rendererService.scene.remove(particleMesh);
this.clearParticleVisuals();
this.renderFrameParticles(this.currentFrame);
});
}
private animatePulse(mesh: THREE.Mesh, cycles: number, onComplete: () => void): void {
const duration = 300;
const maxScale = 0.08 / 0.03;
const startTime = performance.now();
const animate = (time: number) => {
const elapsed = (time - startTime) % duration;
const t = elapsed / (duration / 2);
const scaleFactor = t <= 1 ? 1 + (maxScale - 1) * t : maxScale - (maxScale - 1) * (t - 1);
mesh.scale.setScalar(scaleFactor);
if (time - startTime >= duration * cycles) {
onComplete();
} else {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
private getColor(particleInfo: ParticleInfo) {
if (particleInfo.color) {
const r = parseInt(particleInfo.color.substring(0, 2), 16) / 255;
const g = parseInt(particleInfo.color.substring(2, 4), 16) / 255;
const b = parseInt(particleInfo.color.substring(4, 6), 16) / 255;
return new THREE.Color(r, g, b);
} else {
return new THREE.Color(255, 0, 0);
}
}
/**
* Sets the selected color for new particles
*/
@@ -134,6 +181,22 @@ export class ParticleManagerService {
return this.selectedColor;
}
public get particle(): Particle {
return this.selectedParticle;
}
public set particle(selectedParticle: Particle) {
this.selectedParticle = selectedParticle;
}
public get size(): number {
return this.selectedSize;
}
public set size(selectedSize: number) {
this.selectedSize = selectedSize;
}
/**
* Gets the particle data
*/
@@ -1,6 +1,8 @@
import {ElementRef, Injectable} from '@angular/core';
import * as THREE from 'three';
import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js';
import {ThemeService} from '../../theme/theme.service';
import {THEME_MODE} from '../../constant';
/**
* Service responsible for managing the Three.js rendering environment
@@ -13,6 +15,16 @@ export class RendererService {
camera!: THREE.PerspectiveCamera;
renderer!: THREE.WebGLRenderer;
controls!: OrbitControls;
private currentTheme: THEME_MODE = THEME_MODE.LIGHT;
constructor(private themeService: ThemeService) {
this.themeService.theme$.subscribe(theme => {
this.currentTheme = theme;
if (this.scene) {
this.setBackgroundColor(theme);
}
})
}
/**
* Initializes the Three.js scene, camera, renderer, and controls
@@ -20,7 +32,7 @@ export class RendererService {
initializeRenderer(container: ElementRef): void {
// Create scene
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0xf0f0f0);
this.setBackgroundColor(this.currentTheme);
// Get container dimensions
const containerWidth = container.nativeElement.clientWidth;
@@ -54,6 +66,20 @@ export class RendererService {
this.addLights();
}
private setBackgroundColor(theme: THEME_MODE) {
this.scene.background = new THREE.Color(this.currentTheme === THEME_MODE.DARK ? 0x242526 : 0xFBFBFE);
}
/**
* Resets the camera to its default position and orientation.
*/
public resetCamera(): void {
this.camera.position.set(-1, 2, 3);
this.camera.lookAt(0, 1, 0);
this.controls.target.set(0, 1, 0);
this.controls.update();
}
/**
* Adds lighting to the scene
*/
+15
View File
@@ -10,11 +10,18 @@ components:
schemas:
PermissionClaim:
$ref: './schemas/permissions/permissions.yml#/components/schemas/PermissionClaim'
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
tags:
- name: history
description: Retrieves punishment history
- name: team
description: Retrieves information about the staff team
- name: particles
description: All actions related to particles
paths:
/team/{team}:
$ref: './schemas/team/team.yml#/getTeam'
@@ -46,3 +53,11 @@ paths:
$ref: './schemas/login/login.yml#/RequestNewUserLogin'
/login/userLogin/{code}:
$ref: './schemas/login/login.yml#/UserLogin'
/files/save/{filename}:
$ref: './schemas/particles/particles.yml#/SaveFile'
/files/save/{uuid}/{filename}:
$ref: './schemas/particles/particles.yml#/SaveFileForUser'
/files/download/{filename}/{secret}:
$ref: './schemas/particles/particles.yml#/DownloadFile'
/files/download/{uuid}/{filename}:
$ref: './schemas/particles/particles.yml#/DownloadFileForUser'
@@ -0,0 +1,176 @@
components:
parameters:
Filename:
name: filename
in: path
required: true
schema:
type: string
description: The name of the file
Secret:
name: Authorization
in: header
required: true
schema:
type: string
description: Secret
schemas:
FileData:
type: object
required:
- content
properties:
content:
type: string
format: binary
description: The content of the file
SaveFile:
post:
tags:
- particles
summary: Save a file
description: Save a file to the server (requires authorization)
operationId: saveFile
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/Filename'
requestBody:
required: true
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/FileData'
responses:
'200':
description: File saved successfully
'401':
description: Unauthorized - Invalid or missing token
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
'403':
description: Forbidden - Insufficient permissions
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
SaveFileForUser:
post:
tags:
- particles
summary: Save a file for a specific user
description: Save a file to the server for a specific user (requires head_mod permission)
operationId: saveFileForUser
security:
- bearerAuth: []
parameters:
- $ref: '../generic/parameters.yml#/components/parameters/Uuid'
- $ref: '#/components/parameters/Filename'
requestBody:
required: true
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/FileData'
responses:
'200':
description: File saved successfully
'401':
description: Unauthorized - Invalid or missing token
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
'403':
description: Forbidden - Insufficient permissions
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
DownloadFile:
get:
tags:
- particles
summary: Download a file
description: Download a file from the server using a secret key
operationId: downloadFile
parameters:
- $ref: '#/components/parameters/Secret'
- $ref: '#/components/parameters/Filename'
responses:
'200':
description: File downloaded successfully
content:
application/octet-stream:
schema:
type: string
format: binary
'404':
description: File not found
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
DownloadFileForUser:
get:
tags:
- particles
summary: Download a file for a specific user
description: Download a file from the server for a specific user (requires authorization)
operationId: downloadFileForUser
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/Secret'
- $ref: '../generic/parameters.yml#/components/parameters/Uuid'
- $ref: '#/components/parameters/Filename'
responses:
'200':
description: File downloaded successfully
content:
application/octet-stream:
schema:
type: string
format: binary
'401':
description: Unauthorized - Invalid or missing token
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
'404':
description: File not found
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'