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 { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http return http
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/login/userLogin/**", "/login/requestNewUserLogin/**").permitAll()
.requestMatchers("/team/**", "/history/**").permitAll()
.requestMatchers("/form/**").hasAuthority(PermissionClaimDto.USER.getValue()) .requestMatchers("/form/**").hasAuthority(PermissionClaimDto.USER.getValue())
.requestMatchers("/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.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() .anyRequest().permitAll()
) )
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .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") @RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login")
@Override @Override
public ResponseEntity<String> login(String code) { 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) { if (code == null) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -134,12 +132,12 @@ public class LoginController implements LoginApi {
Instant now = Instant.now(); Instant now = Instant.now();
//TODO make a JWT for renewing and one for storing permissions for a session (expiry 1 hour) //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)); Instant expiryTime = now.plusSeconds(TimeUnit.DAYS.toSeconds(30));
CompletableFuture<PrivilegedUser> privilegedUserCompletableFuture = new CompletableFuture<>(); CompletableFuture<Optional<PrivilegedUser>> privilegedUserCompletableFuture = new CompletableFuture<>();
List<PermissionClaimDto> claimList = new ArrayList<>(); List<PermissionClaimDto> claimList = new ArrayList<>();
Connection.getConnection(Databases.DEFAULT) Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> { .runQuery(sqlSession -> {
try { try {
PrivilegedUser privilegedUser = sqlSession.getMapper(PrivilegedUserMapper.class) Optional<PrivilegedUser> privilegedUser = sqlSession.getMapper(PrivilegedUserMapper.class)
.getUserByUuid(uuid.toString()); .getUserByUuid(uuid.toString());
privilegedUserCompletableFuture.complete(privilegedUser); privilegedUserCompletableFuture.complete(privilegedUser);
@@ -148,17 +146,15 @@ public class LoginController implements LoginApi {
privilegedUserCompletableFuture.completeExceptionally(e); privilegedUserCompletableFuture.completeExceptionally(e);
} }
}); });
PrivilegedUser privilegedUser = privilegedUserCompletableFuture.join(); Optional<PrivilegedUser> privilegedUser = privilegedUserCompletableFuture.join();
claimList.add(PermissionClaimDto.USER); claimList.add(PermissionClaimDto.USER);
if (privilegedUser != null) { privilegedUser.ifPresent(user -> user.getPermissions().forEach(permission -> {
privilegedUser.getPermissions().forEach(permission -> {
try { try {
claimList.add(PermissionClaimDto.valueOf(permission)); claimList.add(PermissionClaimDto.valueOf(permission));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
log.warn("Received invalid permission claim: {}", permission); log.warn("Received invalid permission claim: {}", permission);
} }
}); }));
}
JwtClaimsSet claims = JwtClaimsSet.builder() JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("altitudeweb") .issuer("altitudeweb")
.claim("authorities", claimList.stream().map(PermissionClaimDto::getValue).toList()) .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} database.password=${DB_PASSWORD:root}
cors.allowed-origins=${CORS:https://alttd.com} cors.allowed-origins=${CORS:https://alttd.com}
login.secret=${LOGIN_SECRET:SET_TOKEN} 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 logging.level.com.alttd.altitudeweb=INFO
@@ -1,8 +1,10 @@
package com.alttd.altitudeweb.database.web_db; package com.alttd.altitudeweb.database.web_db;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
import org.jetbrains.annotations.Nullable;
import java.util.List; import java.util.List;
import java.util.Optional;
public interface PrivilegedUserMapper { public interface PrivilegedUserMapper {
@@ -23,7 +25,7 @@ public interface PrivilegedUserMapper {
@Result(property = "permissions", column = "id", javaType = List.class, @Result(property = "permissions", column = "id", javaType = List.class,
many = @Many(select = "getPermissionsForUser")) many = @Many(select = "getPermissionsForUser"))
}) })
PrivilegedUser getUserByUuid(@Param("uuid") String uuid); Optional<PrivilegedUser> getUserByUuid(@Param("uuid") String uuid);
/** /**
* Retrieves all privileged users with their permissions * 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) loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
}, },
{ {
path: 'particles', path: 'login',
loadComponent: () => import('./particles/particles.component').then(m => m.ParticlesComponent) loadComponent: () => import('./login/login.component').then(m => m.LoginDialogComponent)
}, },
]; ];
@@ -1,4 +1,5 @@
<mat-card> <div class="card-div">
<mat-card>
<mat-card-header> <mat-card-header>
<mat-card-title>Frames</mat-card-title> <mat-card-title>Frames</mat-card-title>
</mat-card-header> </mat-card-header>
@@ -11,11 +12,16 @@
<h3>Particles in {{ frameId }}</h3> <h3>Particles in {{ frameId }}</h3>
<div class="particles-list"> <div class="particles-list">
<div *ngFor="let particle of particleData.frames[frameId]; let i = index" class="particle-item"> <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) }} <span class="particle-item-text">
, {{ particle.z.toFixed(2) }})</span> Particle {{ i + 1 }}: ({{ particle.x.toFixed(2) }}, {{ particle.y.toFixed(2) }}
<button mat-icon-button color="warn" (click)="removeParticle(frameId, i)"> , {{ particle.z.toFixed(2) }})
</span>
<button mat-icon-button (click)="removeParticle(frameId, i)">
<mat-icon>delete</mat-icon> <mat-icon>delete</mat-icon>
</button> </button>
<button mat-icon-button (click)="highlightParticle(frameId, i)">
<mat-icon>lightbulb</mat-icon>
</button>
</div> </div>
<div *ngIf="!particleData.frames[frameId] || particleData.frames[frameId].length === 0" <div *ngIf="!particleData.frames[frameId] || particleData.frames[frameId].length === 0"
class="no-particles"> class="no-particles">
@@ -38,4 +44,5 @@
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div>
@@ -17,10 +17,14 @@
.particle-item { .particle-item {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
padding: 8px; padding: 8px;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
gap: 10px;
}
.particle-item-text {
flex-grow: 1;
} }
.particle-item:last-child { .particle-item:last-child {
@@ -30,7 +34,7 @@
.no-particles { .no-particles {
padding: 20px; padding: 20px;
text-align: center; text-align: center;
color: #888; color: var(--color-primairy);
} }
.frame-actions { .frame-actions {
@@ -44,3 +48,14 @@
display: flex; display: flex;
justify-content: center; 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"> <div class="card-div">
<mat-card class="particle-card">
<mat-card-header> <mat-card-header>
<mat-card-title>Particle Color</mat-card-title> <mat-card-title>Particle Properties</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<div class="particle-properties">
<div class="property-row">
<div class="color-picker"> <div class="color-picker">
<input type="color" [(ngModel)]="selectedColor"> <input type="color" [(ngModel)]="selectedColor">
<span>Selected Color: {{ selectedColor }}</span> <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> </div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div>
@@ -1,17 +1,61 @@
.color-picker-card { .particle-card {
margin-top: 20px; 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 { .color-picker {
display: flex; display: flex;
flex: 1;
align-items: center; align-items: center;
gap: 15px; gap: 10px;
} }
.color-picker input[type="color"] { .color-picker input[type="color"] {
width: 50px; width: 40px;
height: 50px; height: 40px;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; 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 {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 {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({ @Component({
selector: 'app-particle', selector: 'app-particle',
@@ -11,16 +20,55 @@ import {ParticleManagerService} from '../../services/particle-manager.service';
MatCardHeader, MatCardHeader,
MatCardTitle, MatCardTitle,
ReactiveFormsModule, ReactiveFormsModule,
FormsModule FormsModule,
MatSliderModule,
MatSelectModule,
MatFormFieldModule,
MatInputModule,
MatAutocompleteModule,
NgForOf,
AsyncPipe
], ],
templateUrl: './particle.component.html', templateUrl: './particle.component.html',
styleUrl: './particle.component.scss' 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( constructor(
private particleManagerService: ParticleManagerService, 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); 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,4 +1,5 @@
<mat-card> <div class="card-div">
<mat-card>
<mat-card-header> <mat-card-header>
<mat-card-title>Particle Properties</mat-card-title> <mat-card-title>Particle Properties</mat-card-title>
</mat-card-header> </mat-card-header>
@@ -85,7 +86,8 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<mat-checkbox [(ngModel)]="particleData.stationary">Stationary</mat-checkbox> <mat-checkbox [(ngModel)]="particleData.stationary"><span>Stationary</span></mat-checkbox>
</div> </div>
</mat-card-content> </mat-card-content>
</mat-card> </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 #rendererContainer class="renderer-container">
<div class="plane-controls-overlay"> <div class="plane-controls-overlay">
<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()" <button mat-mini-fab color="primary" (click)="togglePlaneLock()"
[matTooltip]="isPlaneLocked ? 'Unlock Plane' : 'Lock Plane'"> [matTooltip]="isPlaneLocked ? 'Unlock Plane' : 'Lock Plane'">
<mat-icon>{{ isPlaneLocked ? 'lock' : 'lock_open' }}</mat-icon> <mat-icon>{{ isPlaneLocked ? 'lock' : 'lock_open' }}</mat-icon>
</button> </button>
</div>
<div *ngIf="isPlaneLocked" class="plane-orientation-buttons"> <div *ngIf="isPlaneLocked" class="plane-orientation-buttons">
<button mat-mini-fab color="warn" (click)="setPlaneOrientation(planeOrientations.VERTICAL_ABOVE)" <button mat-mini-fab color="warn" (click)="setPlaneOrientation(planeOrientations.VERTICAL_ABOVE)"
@@ -3,7 +3,7 @@
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
background-color: #f0f0f0; background-color: var(--color-primairy);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -21,6 +21,13 @@
z-index: 10; z-index: 10;
} }
.button-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 10px;
}
.plane-orientation-buttons { .plane-orientation-buttons {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@@ -42,5 +49,5 @@
.plane-orientation-buttons button.active { .plane-orientation-buttons button.active {
opacity: 1; opacity: 1;
transform: scale(1.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 {RendererService} from '../../services/renderer.service';
import {PlayerModelService} from '../../services/player-model.service'; import {PlayerModelService} from '../../services/player-model.service';
import {InputHandlerService} from '../../services/input-handler.service'; import {InputHandlerService} from '../../services/input-handler.service';
import {FormsModule} from '@angular/forms';
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
@Component({ @Component({
selector: 'app-render-container', selector: 'app-render-container',
@@ -14,7 +16,11 @@ import {InputHandlerService} from '../../services/input-handler.service';
MatIcon, MatIcon,
MatMiniFabButton, MatMiniFabButton,
MatTooltip, MatTooltip,
NgIf NgIf,
FormsModule,
MatInput,
MatFormField,
MatLabel
], ],
templateUrl: './render-container.component.html', templateUrl: './render-container.component.html',
styleUrl: './render-container.component.scss' styleUrl: './render-container.component.scss'
@@ -74,6 +80,14 @@ export class RenderContainerComponent implements AfterViewInit, OnDestroy {
return this.intersectionPlaneService.isPlaneLocked(); 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 * Toggle the plane locked state
*/ */
@@ -82,6 +96,10 @@ export class RenderContainerComponent implements AfterViewInit, OnDestroy {
this.intersectionPlaneService.setPlaneLocked(newLockedState); this.intersectionPlaneService.setPlaneLocked(newLockedState);
} }
public resetCamera(): void {
this.rendererService.resetCamera();
}
/** /**
* Get the current plane orientation * Get the current plane orientation
*/ */
@@ -1,21 +1,35 @@
/** /**
* Defines the types of particles available in the system * Defines the types of particles available in the system
*/ */
export enum ParticleType { export enum Particle {
REDSTONE = 'REDSTONE', DUST = 'DUST',
DUST_COLOR_TRANSITION = 'DUST_COLOR_TRANSITION',
TINTED_LEAVES = 'TINTED_LEAVES'
// Other particle types can be added later // 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 * Represents a single particle's information
*/ */
export interface ParticleInfo { export interface ParticleInfo {
particle_type: string; particle_type: Particle;
x: number; x: number;
y: number; y: number;
z: number; z: number;
color: string; extra?: number;
extra: number; color?: string;
color_gradient_end?: string;
size?: number;
} }
/** /**
@@ -16,10 +16,14 @@
gap: 20px; gap: 20px;
} }
label, span {
color: var(--font-color);
}
.plane-controls { .plane-controls {
margin-top: 10px; margin-top: 10px;
padding: 10px; padding: 10px;
background-color: #f5f5f5; background-color: var(--color-primairy);
border-radius: 4px; border-radius: 4px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -30,10 +34,92 @@
flex: 1; flex: 1;
} }
.form-row { :host ::ng-deep {
margin-bottom: 15px; .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 {FramesComponent} from './components/frames/frames.component';
import {MatSnackBar} from '@angular/material/snack-bar'; import {MatSnackBar} from '@angular/material/snack-bar';
import {RenderContainerComponent} from './components/render-container/render-container.component'; import {RenderContainerComponent} from './components/render-container/render-container.component';
import {ParticlesService} from '../../api';
@Component({ @Component({
selector: 'app-particles', selector: 'app-particles',
@@ -55,6 +56,7 @@ export class ParticlesComponent {
private intersectionPlaneService: IntersectionPlaneService, private intersectionPlaneService: IntersectionPlaneService,
private particleManagerService: ParticleManagerService, private particleManagerService: ParticleManagerService,
private matSnackBar: MatSnackBar, private matSnackBar: MatSnackBar,
private particlesService: ParticlesService,
) { ) {
} }
@@ -93,9 +95,37 @@ export class ParticlesComponent {
return this.particleManagerService.generateJson(); 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() { public copyJson() {
navigator.clipboard.writeText(this.generateJson()).then(() => { navigator.clipboard.writeText(this.generateJson()).then(() => {
this.matSnackBar.open('Copied to clipboard', '', {duration: 2000}) 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 planePosition: number = 0; // Position in 1/16th of a block
private currentOrientation: PlaneOrientation = PlaneOrientation.HORIZONTAL_FRONT; private currentOrientation: PlaneOrientation = PlaneOrientation.HORIZONTAL_FRONT;
private planeLocked: boolean = false; private planeLocked: boolean = false;
private opacity: number = 0.05;
constructor(private rendererService: RendererService) { constructor(private rendererService: RendererService) {
} }
@@ -152,11 +153,19 @@ export class IntersectionPlaneService {
this.intersectionPlane.material = new THREE.MeshBasicMaterial({ this.intersectionPlane.material = new THREE.MeshBasicMaterial({
color: color, color: color,
transparent: true, transparent: true,
opacity: 0.05, opacity: this.opacity,
side: THREE.DoubleSide side: THREE.DoubleSide
}); });
} }
public set currentOpacity(opacity: number) {
this.opacity = opacity;
}
public get currentOpacity(): number {
return this.opacity;
}
/** /**
* Gets the intersection plane * Gets the intersection plane
*/ */
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import {Injectable} from '@angular/core';
import * as THREE from 'three'; import * as THREE from 'three';
import { RendererService } from './renderer.service'; import {RendererService} from './renderer.service';
import { ParticleData, ParticleInfo, ParticleType } from '../models/particle.model'; import {Particle, ParticleData, ParticleInfo, ParticleType} from '../models/particle.model';
/** /**
* Service responsible for managing particles in the scene * Service responsible for managing particles in the scene
@@ -14,9 +14,9 @@ export class ParticleManagerService {
private particleData: ParticleData = { private particleData: ParticleData = {
particle_name: '', particle_name: '',
display_name: '', display_name: '',
particle_type: ParticleType.REDSTONE, particle_type: ParticleType.TRAIL,
lore: '', lore: '',
display_item: 'REDSTONE', display_item: 'DIRT',
permission: '', permission: '',
package_permission: '', package_permission: '',
frame_delay: 1, frame_delay: 1,
@@ -31,15 +31,18 @@ export class ParticleManagerService {
private currentFrame: string = 'frame1'; private currentFrame: string = 'frame1';
private frames: string[] = ['frame1']; private frames: string[] = ['frame1'];
private selectedColor: string = '#ff0000'; 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 * Adds a particle at the specified position
*/ */
addParticle(x: number, y: number, z: number): void { addParticle(x: number, y: number, z: number): void {
// Create a visual representation of the particle // 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 particleMaterial = new THREE.MeshBasicMaterial({color: this.selectedColor});
const particleMesh = new THREE.Mesh(particleGeometry, particleMaterial); const particleMesh = new THREE.Mesh(particleGeometry, particleMaterial);
@@ -49,17 +52,17 @@ export class ParticleManagerService {
// Add to particle data // Add to particle data
const hexColor = this.selectedColor.replace('#', ''); 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 = { const particleInfo: ParticleInfo = {
particle_type: ParticleType.REDSTONE, particle_type: this.selectedParticle,
x: x, x: x,
y: y, y: y,
z: z, z: z,
color: `${r},${g},${b}`, color: hexColor,
extra: 1 // color_gradient_end: hexColor2,
extra: 1,
size: this.selectedSize
}; };
if (!this.particleData.frames[this.currentFrame]) { if (!this.particleData.frames[this.currentFrame]) {
@@ -86,16 +89,9 @@ export class ParticleManagerService {
if (!this.particleData.frames[frameId]) return; if (!this.particleData.frames[frameId]) return;
for (const particleInfo of this.particleData.frames[frameId]) { for (const particleInfo of this.particleData.frames[frameId]) {
const particleGeometry = new THREE.SphereGeometry(0.03, 16, 16); const particleGeometry = new THREE.SphereGeometry(0.03 * (particleInfo.size ?? 1), 16, 16);
// Parse color
const colorParts = particleInfo.color.split(',');
const color = new THREE.Color(
parseFloat(colorParts[0]),
parseFloat(colorParts[1]),
parseFloat(colorParts[2])
);
const color = this.getColor(particleInfo);
const particleMaterial = new THREE.MeshBasicMaterial({color}); const particleMaterial = new THREE.MeshBasicMaterial({color});
const particleMesh = new THREE.Mesh(particleGeometry, particleMaterial); 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 * Sets the selected color for new particles
*/ */
@@ -134,6 +181,22 @@ export class ParticleManagerService {
return this.selectedColor; 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 * Gets the particle data
*/ */
@@ -1,6 +1,8 @@
import {ElementRef, Injectable} from '@angular/core'; import {ElementRef, Injectable} from '@angular/core';
import * as THREE from 'three'; import * as THREE from 'three';
import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js'; 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 * Service responsible for managing the Three.js rendering environment
@@ -13,6 +15,16 @@ export class RendererService {
camera!: THREE.PerspectiveCamera; camera!: THREE.PerspectiveCamera;
renderer!: THREE.WebGLRenderer; renderer!: THREE.WebGLRenderer;
controls!: OrbitControls; 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 * Initializes the Three.js scene, camera, renderer, and controls
@@ -20,7 +32,7 @@ export class RendererService {
initializeRenderer(container: ElementRef): void { initializeRenderer(container: ElementRef): void {
// Create scene // Create scene
this.scene = new THREE.Scene(); this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0xf0f0f0); this.setBackgroundColor(this.currentTheme);
// Get container dimensions // Get container dimensions
const containerWidth = container.nativeElement.clientWidth; const containerWidth = container.nativeElement.clientWidth;
@@ -54,6 +66,20 @@ export class RendererService {
this.addLights(); 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 * Adds lighting to the scene
*/ */
+15
View File
@@ -10,11 +10,18 @@ components:
schemas: schemas:
PermissionClaim: PermissionClaim:
$ref: './schemas/permissions/permissions.yml#/components/schemas/PermissionClaim' $ref: './schemas/permissions/permissions.yml#/components/schemas/PermissionClaim'
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
tags: tags:
- name: history - name: history
description: Retrieves punishment history description: Retrieves punishment history
- name: team - name: team
description: Retrieves information about the staff team description: Retrieves information about the staff team
- name: particles
description: All actions related to particles
paths: paths:
/team/{team}: /team/{team}:
$ref: './schemas/team/team.yml#/getTeam' $ref: './schemas/team/team.yml#/getTeam'
@@ -46,3 +53,11 @@ paths:
$ref: './schemas/login/login.yml#/RequestNewUserLogin' $ref: './schemas/login/login.yml#/RequestNewUserLogin'
/login/userLogin/{code}: /login/userLogin/{code}:
$ref: './schemas/login/login.yml#/UserLogin' $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'