21 Commits
Author SHA1 Message Date
Peter 54e747118c Merge branch 'bans' 2025-07-06 18:15:09 +02:00
Peter 43430cfbef Merge remote-tracking branch 'origin/bans' into bans
# Conflicts:
#	frontend/src/app/app.routes.ts
2025-07-06 11:13:59 +02:00
stijn cce83a08de Replace fakeLogin() with actual login() method in AuthService and remove redundant fakeLogin() implementation. 2025-07-04 23:32:08 +02:00
stijn f0faa63ca7 Add JWT support for authentication handling
Integrate `@auth0/angular-jwt` for Token management. Update `app.config.ts` with `JwtModule` setup and token getter from cookies. Enhance `AuthService` to include token handling, fake login, and JWT validation using `JwtHelperService`. Introduce `JwtClaims` interface for structured token claims.
2025-07-04 22:31:41 +02:00
stijn dfea91d8ca Add PrivilegedUserMapper to InitializeWebDb setup 2025-07-04 21:37:11 +02:00
stijn 73916f0aae Add login button to header 2025-07-04 21:14:45 +02:00
stijn ebe66c87c0 Rework folder structure in frontend
Pages are now grouped per group they appear in on in the header (where possible)
Utilities used by multiple pages in the project are grouped in folders such as services/pipes/etc
2025-07-04 19:50:21 +02:00
stijn c42fc38b2c Add SecurityAuthFailureHandler for better handling of authentication and access failures; update SecurityConfig to integrate the new handler. 2025-07-04 19:49:04 +02:00
stijn 213f9987d9 Remove particle component and its associated routes and security controls. 2025-07-03 20:08:56 +02:00
stijn 48cac607de Add route for lazy-loaded Login component. 2025-07-03 20:02:07 +02:00
stijn 6ed2e15017 Parametrize notification server URL configuration for improved flexibility. 2025-06-29 03:17:25 +02:00
stijn 7fc25f46f3 Add endpoints, services, and security controls for particle file management, including save and download APIs. 2025-06-29 03:15:39 +02:00
stijn c72703ea32 Refactor user privilege handling to use Optional instead of null checks. Remove unused cache entries and update security configuration to refine access controls. 2025-06-23 21:34:54 +02:00
auto e837a9216d Fix slider sticking out of page 2025-06-23 00:25:31 +02:00
auto d4363b3a8a Add particle type selection, size control, and enhance particle property handling 2025-06-23 00:23:03 +02:00
auto 1e5862bae6 Add new particle types and enhance particle attributes handling 2025-06-23 00:04:30 +02:00
auto daf88ea437 Add opacity control for intersection plane 2025-06-22 23:24:06 +02:00
auto 9abd570b87 Add support for darkmode 2025-06-22 23:15:06 +02:00
auto 5284d498f3 Add a reset camera button and implement default camera reset functionality 2025-06-22 20:53:11 +02:00
auto c3a7be82e9 Add an option to highlight particles 2025-06-22 20:46:08 +02:00
Peter 174ed834ca Added pages and fitting content for community, nickgenerator annd nicknames 2025-05-30 23:07:42 +02:00
200 changed files with 1774 additions and 449 deletions
@@ -0,0 +1,36 @@
package com.alttd.altitudeweb.config;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Slf4j
@Component
public class SecurityAuthFailureHandler implements AccessDeniedHandler, AuthenticationEntryPoint {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
log.warn("Access denied: User '{}' attempted to access '{}' without proper permissions",
request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : "unknown",
request.getRequestURI());
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied");
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
log.warn("Authentication failure: Unauthenticated user attempted to access secured endpoint '{}'",
request.getRequestURI());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication Required");
}
}
@@ -31,20 +31,34 @@ import java.security.interfaces.RSAPublicKey;
public class SecurityConfig {
private final KeyPairService keyPairService;
private final SecurityAuthFailureHandler securityAuthFailureHandler;
@Bean
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())
.anyRequest().permitAll()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build();
.authorizeHttpRequests(
auth -> auth
.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())
.authenticationEntryPoint(securityAuthFailureHandler)
.accessDeniedHandler(securityAuthFailureHandler)
)
.exceptionHandling(
ex -> ex
.authenticationEntryPoint(securityAuthFailureHandler)
.accessDeniedHandler(securityAuthFailureHandler)
)
.sessionManagement(
session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build();
}
@Bean
@@ -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,6 +2,7 @@ package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
@@ -18,6 +19,7 @@ public class InitializeWebDb {
Connection.getConnection(Databases.DEFAULT, (configuration) -> {
configuration.addMapper(SettingsMapper.class);
configuration.addMapper(KeyPairMapper.class);
configuration.addMapper(PrivilegedUserMapper.class);
}).join()
.runQuery(SqlSession -> {
createSettingsTable(SqlSession);
+1
View File
@@ -22,6 +22,7 @@
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"@auth0/angular-jwt": "^5.2.0",
"@types/three": "^0.177.0",
"ngx-cookie-service": "^19.1.2",
"rxjs": "~7.8.0",
+4 -4
View File
@@ -1,8 +1,8 @@
import {Component, OnInit} from '@angular/core';
import {Meta, Title} from '@angular/platform-browser';
import {ALTITUDE_VERSION} from './constant';
import {ALTITUDE_VERSION} from '@custom-types/constant';
import {Router, RouterOutlet} from '@angular/router';
import {FooterComponent} from './footer/footer.component';
import {FooterComponent} from '@pages/footer/footer/footer.component';
@Component({
standalone: true,
@@ -10,8 +10,8 @@ import {FooterComponent} from './footer/footer.component';
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
imports: [
FooterComponent,
RouterOutlet
RouterOutlet,
FooterComponent
]
})
export class AppComponent implements OnInit {
+19 -1
View File
@@ -1,8 +1,26 @@
import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router';
import {JwtHelperService, JwtModule} from '@auth0/angular-jwt';
import {CookieService} from 'ngx-cookie-service';
import {routes} from './app.routes';
// Function to get the JWT token from cookies
export function jwtTokenGetter() {
const cookieService = new CookieService(document, null);
return cookieService.check('jwt') ? cookieService.get('jwt') : null;
}
export const appConfig: ApplicationConfig = {
providers: [provideZoneChangeDetection({eventCoalescing: true}), provideRouter(routes)]
providers: [
provideZoneChangeDetection({eventCoalescing: true}),
provideRouter(routes),
{ provide: CookieService, useClass: CookieService },
{ provide: JwtHelperService, useClass: JwtHelperService },
{ provide: JwtModule, useValue: JwtModule.forRoot({
config: {
tokenGetter: jwtTokenGetter
}
})}
]
};
+28 -32
View File
@@ -3,114 +3,110 @@ import {Routes} from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent)
loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent)
},
{
path: 'particles',
loadComponent: () => import('./particles/particles.component').then(m => m.ParticlesComponent)
loadComponent: () => import('./pages/particles/particles.component').then(m => m.ParticlesComponent)
},
{
path: 'map',
loadComponent: () => import('./map/map.component').then(m => m.MapComponent)
loadComponent: () => import('./pages/features/map/map.component').then(m => m.MapComponent)
},
{
path: 'rules',
loadComponent: () => import('./rules/rules.component').then(m => m.RulesComponent)
loadComponent: () => import('./pages/reference/rules/rules.component').then(m => m.RulesComponent)
},
{
path: 'vote',
loadComponent: () => import('./vote/vote.component').then(m => m.VoteComponent)
loadComponent: () => import('./pages/vote/vote.component').then(m => m.VoteComponent)
},
{
path: 'about',
loadComponent: () => import('./about/about.component').then(m => m.AboutComponent)
loadComponent: () => import('./pages/altitude/about/about.component').then(m => m.AboutComponent)
},
{
path: 'socials',
loadComponent: () => import('./socials/socials.component').then(m => m.SocialsComponent)
loadComponent: () => import('./pages/altitude/socials/socials.component').then(m => m.SocialsComponent)
},
{
path: 'team',
loadComponent: () => import('./team/team.component').then(m => m.TeamComponent)
loadComponent: () => import('./pages/altitude/team/team.component').then(m => m.TeamComponent)
},
{
path: 'birthdays',
loadComponent: () => import('./birthdays/birthdays.component').then(m => m.BirthdaysComponent)
loadComponent: () => import('./pages/altitude/birthdays/birthdays.component').then(m => m.BirthdaysComponent)
},
{
path: 'terms',
loadComponent: () => import('./terms/terms.component').then(m => m.TermsComponent)
loadComponent: () => import('./pages/footer/terms/terms.component').then(m => m.TermsComponent)
},
{
path: 'privacy',
loadComponent: () => import('./privacy/privacy.component').then(m => m.PrivacyComponent)
loadComponent: () => import('./pages/footer/privacy/privacy.component').then(m => m.PrivacyComponent)
},
{
path: 'bans',
loadComponent: () => import('./bans/bans.component').then(m => m.BansComponent)
loadComponent: () => import('./pages/reference/bans/bans.component').then(m => m.BansComponent)
},
{
path: 'bans/:type/:id',
loadComponent: () => import('./bans/details/details.component').then(m => m.DetailsComponent)
loadComponent: () => import('./pages/reference/bans/details/details.component').then(m => m.DetailsComponent)
},
{
path: 'economy',
loadComponent: () => import('./economy/economy.component').then(m => m.EconomyComponent)
loadComponent: () => import('./pages/features/economy/economy.component').then(m => m.EconomyComponent)
},
{
path: 'claiming',
loadComponent: () => import('./claiming/claiming.component').then(m => m.ClaimingComponent)
loadComponent: () => import('./pages/features/claiming/claiming.component').then(m => m.ClaimingComponent)
},
{
path: 'mypet',
loadComponent: () => import('./mypet/mypet.component').then(m => m.MypetComponent)
loadComponent: () => import('./pages/features/mypet/mypet.component').then(m => m.MypetComponent)
},
{
path: 'warps',
loadComponent: () => import('./warps/warps.component').then(m => m.WarpsComponent)
loadComponent: () => import('./pages/features/warps/warps.component').then(m => m.WarpsComponent)
},
{
path: 'skyblock',
loadComponent: () => import('./skyblock/skyblock.component').then(m => m.SkyblockComponent)
loadComponent: () => import('./pages/features/skyblock/skyblock.component').then(m => m.SkyblockComponent)
},
{
path: 'customfeatures',
loadComponent: () => import('./customfeatures/customfeatures.component').then(m => m.CustomfeaturesComponent)
loadComponent: () => import('./pages/features/customfeatures/customfeatures.component').then(m => m.CustomfeaturesComponent)
},
{
path: 'guide',
loadComponent: () => import('./guide/guide.component').then(m => m.GuideComponent)
loadComponent: () => import('./pages/reference/guide/guide.component').then(m => m.GuideComponent)
},
{
path: 'ranks',
loadComponent: () => import('./ranks/ranks.component').then(m => m.RanksComponent)
loadComponent: () => import('./pages/reference/ranks/ranks.component').then(m => m.RanksComponent)
},
{
path: 'commandlist',
loadComponent: () => import('./commandlist/commandlist.component').then(m => m.CommandlistComponent)
loadComponent: () => import('./pages/reference/commandlist/commandlist.component').then(m => m.CommandlistComponent)
},
{
path: 'mapart',
loadComponent: () => import('./mapart/mapart.component').then(m => m.MapartComponent)
loadComponent: () => import('./pages/reference/mapart/mapart.component').then(m => m.MapartComponent)
},
{
path: 'lag',
loadComponent: () => import('./lag/lag.component').then(m => m.LagComponent)
loadComponent: () => import('./pages/reference/lag/lag.component').then(m => m.LagComponent)
},
{
path: 'staffpowers',
loadComponent: () => import('./staffpowers/staffpowers.component').then(m => m.StaffpowersComponent)
loadComponent: () => import('./pages/reference/staffpowers/staffpowers.component').then(m => m.StaffpowersComponent)
},
{
path: 'forms/:form',
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
},
{
path: 'forms',
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
},
{
path: 'particles',
loadComponent: () => import('./particles/particles.component').then(m => m.ParticlesComponent)
},
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
}
];
@@ -0,0 +1,79 @@
<ng-container>
<app-header [current_page]="'community'" height="460px" background_image="/public/img/backgrounds/community.jpg"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Community</h1>
<h2>Talented people who help Altitude in more than one way.</h2>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<div class="customContainer">
<h2>Current Nitro Boosters</h2>
</div>
</section>
<section id="social" class="darkmodeSectionThree">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">Social Media</h2>
</div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
<p style="text-align: center;">We're currently not looking for more people to help manage our socials.</p>
</div>
</section>
<section id="crateTeam" class="darkmodeSection">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">Crate Team</h2>
</div>
</section>
<section class="darkmodeSectionThree">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">Event Leaders</h2>
</div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
<p style="text-align: center;">We're currently not looking for more Event Leaders.</p>
</div>
</section>
<section class="darkmodeSection">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">Event Team</h2>
</div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
<div style="flex-direction: column;">
<p style="text-align: center;">We occasionally open applications for the event team.</p>
<p style="text-align: center;">If you're interested in joining you simply need to keep an eye on the Discord
announcements.</p>
</div>
</div>
</section>
<section class="darkmodeSectionThree">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">YouTubers & Streamers</h2>
</div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
<div style="flex-direction: column;">
<p style="text-align: center;"><a style="cursor: pointer;" id="reqButton">Show Requirements...</a></p>
</div>
</div>
<div id="req" class="hide" style="display: flex; justify-content: center; padding-bottom: 30px;">
<div style="flex-direction: column; justify-content: center; max-width: 800px;">
<p style="text-align: center;"><span style="font-family: 'opensans-bold', sans-serif;">Requirements:</span>
</p>
<p style="text-align: center;">You need to have at least one recent stream/video on Altitude which we can use
to gauge if your audience enjoys your content on Altitude.</p>
<br>
<p style="text-align: center;">Twitch: You need to be affiliate and get at least 5 viewers on average while
streaming on Altitude.</p>
<p style="text-align: center;">YouTube videos: You need at least 500 subs and have at least 200 views per
video within a week on average for Altitude content.</p>
<p style="text-align: center;">YouTube streamers: You need at least 500 subs and have at least 5 viewers on
average while streaming on Altitude.</p>
<br>
<p style="text-align: center;"><span style="font-family: 'opensans-bold', sans-serif;">Note:</span> Before
accepting or denying you we will watch your latest video/stream on Altitude (so keep your broadcasts public
on twitch).</p>
</div>
</div>
</section>
</main>
</ng-container>
@@ -0,0 +1,12 @@
.customContainer {
width: 80%;
max-width: 1020px;
margin: auto;
padding: 80px 0;
text-align: center;
}
.hide {
display: none !important;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CommunityComponent } from './community.component';
describe('CommunityComponent', () => {
let component: CommunityComponent;
let fixture: ComponentFixture<CommunityComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CommunityComponent]
})
.compileComponents();
fixture = TestBed.createComponent(CommunityComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,14 @@
import {Component} from '@angular/core';
import {HeaderComponent} from "../header/header.component";
@Component({
selector: 'app-community',
imports: [
HeaderComponent
],
templateUrl: './community.component.html',
styleUrl: './community.component.scss'
})
export class CommunityComponent {
}
@@ -1,18 +0,0 @@
<h2 mat-dialog-title>Login</h2>
<div mat-dialog-content>
<form [formGroup]="loginForm">
<mat-form-field appearance="fill" style="width: 100%">
<mat-label>Enter your code</mat-label>
<input matInput formControlName="code" type="text">
<mat-error *ngIf="formHasError()">
Code is required
</mat-error>
</mat-form-field>
</form>
</div>
<div mat-dialog-actions align="end">
<button mat-button (click)="onCancel()">Cancel</button>
<button mat-flat-button color="primary" (click)="onSubmit()" [disabled]="!loginForm.valid">
Submit
</button>
</div>
@@ -0,0 +1,53 @@
<ng-container>
<app-header [current_page]="'nickgenerator'" height="460px" background_image="/public/img/backgrounds/trees.jpg"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Nickname Generator</h1>
<h2>Customize your in-game nickname</h2>
<h3 style="font-family: 'minecraft-text', sans-serif; font-size: 0.8rem; margin-top: 10px;">Made by TheParm</h3>
</div>
</app-header>
<main>
<!-- <section class="darkmodeSection">
<div class="container containerNick">
<div style="padding: 0 5% 0 5%;">
<div id="parts" class="previewNickDiv">
</div>
<div class="previewNickDiv">
<input type="button" class="button" value="Add Part" onclick="addPart()"/>
<input type="button" class="button" value="Remove Part" onclick="deletePart()"/>
</div>
<br><br><br><br>
<div id="commandTry" class="previewNickDiv">
<div id="try" class="command darkBg"></div>
<input type="button" class="button copy" value="Copy" onclick="copy(this)"/>
</div>
<div id="commandRequest" class="previewNickDiv">
<div id="request" class="command darkBg"></div>
<input type="button" class="button copy" value="Copy" onclick="copy(this)"/>
</div>
<div id="preview" class="preview darkBg previewNickDiv">
</div>
<div id="template" class='part' style="display: none">
<p style="font-family: 'minecraft-text', sans-serif">
Text: <input type="text" id="text" class="textPart" size=18 oninput="inputChanged()"/>
Gradient: <input type="checkbox" id="grad" class="gradPart" oninput="onGradient(this)"/>
<input id="colorA" type="text" class="coloris colorAPart color" value="#ffffff" oninput="inputChanged()"/>
<input id="colorB" type="text" class="coloris colorBPart color" value="#ffffff" oninput="inputChanged()"/>
Continuation: <input type="checkbox" id="cont" class="contPart" disabled oninput="onContinuation(this)"/>
<span id="invalid" class="invalidPart" style="display: none">(min 1 - max 16 chars)</span>
</p>
</div>
<div style="margin-top: 20px; text-align: center;">
<p style="font-family: 'minecraft-text', sans-serif">
Usage: Add as many parts as you wish, then apply the color and/or gradient, and copy/paste the command
into the minecraft chat. The total length of the nickname should be between 3 and 16 characters. Use the
continuation checkbox to continue the gradient from the last gradient color.
</p>
</div>
</div>
</div>
</section> -->
</main>
</ng-container>
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NickgeneratorComponent } from './nickgenerator.component';
describe('NickgeneratorComponent', () => {
let component: NickgeneratorComponent;
let fixture: ComponentFixture<NickgeneratorComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NickgeneratorComponent]
})
.compileComponents();
fixture = TestBed.createComponent(NickgeneratorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,14 @@
import {Component} from '@angular/core';
import {HeaderComponent} from "../header/header.component";
@Component({
selector: 'app-nickgenerator',
imports: [
HeaderComponent
],
templateUrl: './nickgenerator.component.html',
styleUrl: './nickgenerator.component.scss'
})
export class NickgeneratorComponent {
}
@@ -0,0 +1,82 @@
<ng-container>
<app-header [current_page]="'nicknames'" height="460px" background_image="/public/img/backgrounds/trees.jpg"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>How To Get A Nickname</h1>
<h2>Personalize your writing and nickname in-game by choosing some of the millions of custom colors that we have
to offer!</h2>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<section class="columnSection">
<div class="columnContainer">
<div class="columnParagraph">
<h2>Creating a Nickname</h2>
<p>Donors that have the Duke or Archduke rank have the ability to create custom nicknames for themselves. A
nickname should be similar enough to the players username for that player to be identifiable, and it
<span style="font-family: 'opensans-bold', sans-serif;">must not exceed 16 characters in length</span>. In
general, a nickname should include the main part of a players actual username to avoid confusion.</p>
</div>
<div class="columnParagraph">
<h2>Make it Your Own</h2>
<p>Altitude now supports the full range of RGB colors, which includes more than 16 million different hues.
In addition, the previous basic color codes still exist and are able to be used in combination with the
RGB colors. You can still use <span style="font-family: 'opensans-bold', sans-serif;">/colors</span> to
see the basic color codes that are available for use. You can also make use of <span
style="font-family: 'opensans-bold', sans-serif;">/colorsextra</span> which shows many different RGB
color options. These colors will be in the form of <span
style="font-family: 'opensans-bold', sans-serif;">#XXXXXX</span>; write down the colors that you like so
that you can easily use them later.</p>
<img ngSrc="/public/img/random/colors.png" alt="RGB colors" style="width: 100%; padding:0;" height="130"
width="480">
<p>Output of /colorsextra</p>
<p>Furthermore, you can also use the <a [routerLink]="['/nickgenerator']">nickname generation tool</a> to
make and preview potential nicknames - as well as copying the commands to use in-game.</p>
<p>Players are encouraged to play around and personalize their nicknames, as long as they follow the rules
and are legible! There are endless possibilities beyond simple gradients as well; players can create any
combination of standard colors and gradients that they choose.</p>
<p>Even if you arent a Duke or Archduke, you can try out colors like this on signs in-game.</p>
</div>
</div>
<div class="columnContainer">
<div class="columnParagraph">
<h2>Request Your Nickname</h2>
<p>Players can only submit a new nickname request using <span
style="font-family: 'opensans-bold', sans-serif;">/nick request &lt;name&gt;</span> once per day. When a
nickname is requested, it will notify staff for near-instant approval. Creating custom nicknames using RGB
can be difficult, so we have implemented <span style="font-family: 'opensans-bold', sans-serif;">/nick try &lt;name&gt;</span>
so that you can experiment with your nicknames as many times as you want. It is highly encouraged to use
this command before submitting a formal request to staff. You can select an RGB color by putting the <span
style="font-family: 'opensans-bold', sans-serif;">#XXXXXX</span> code into curly braces before your
text.</p>
<p>We also support gradients in nicknames. You can specify the start and finish color and your nickname will
automatically find a gradient that goes between the two endpoints. <span
style="font-family: 'opensans-bold', sans-serif;">The symbol &gt; is used to start a gradient, and the symbol &lt; is used to close a gradient.</span>
You can have as many gradients as you want in your nickname, as long as it is readable.</p>
<p><span style="font-family: 'opensans-bold', sans-serif;">/nick try &lbrace;#003380&rbrace;Player</span>
would result in a blue name that read as “Player”.</p>
<p><span style="font-family: 'opensans-bold', sans-serif;">/nick try &lbrace;#003380>&rbrace;Player&lbrace;#0000FF<&rbrace;</span>
would result in the nickname reading as “Player” with a blue gradient fading across the letters in the
name.</p>
</div>
<div class="columnParagraph">
<h2>Useful Commands</h2>
<ul>
<li><span style="font-family: 'opensans-bold', sans-serif;">/nick help</span> - Shows a list of all useful
nickname commands and how to use them.
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/nick try name</span> - Allows players to see
how a nickname would look as many times as you like.
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/nick request name</span> - Allows players to
submit a new nickname to staff for approval. This function can only be used once per day.
</li>
</ul>
</div>
</div>
</section>
</section>
</main>
</ng-container>
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NicknamesComponent } from './nicknames.component';
describe('NicknamesComponent', () => {
let component: NicknamesComponent;
let fixture: ComponentFixture<NicknamesComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NicknamesComponent]
})
.compileComponents();
fixture = TestBed.createComponent(NicknamesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,18 @@
import {Component} from '@angular/core';
import {HeaderComponent} from "../header/header.component";
import {NgOptimizedImage} from '@angular/common';
import {RouterLink} from '@angular/router';
@Component({
selector: 'app-nicknames',
imports: [
HeaderComponent,
NgOptimizedImage,
RouterLink
],
templateUrl: './nicknames.component.html',
styleUrl: './nicknames.component.scss'
})
export class NicknamesComponent {
}
@@ -1,7 +1,7 @@
import {Component} from '@angular/core';
import {ScrollService} from '../scroll/scroll.service';
import {ScrollService} from '@services/scroll.service';
import {CommonModule} from '@angular/common';
import {HeaderComponent} from '../header/header.component';
import {HeaderComponent} from '@header/header.component';
@Component({
selector: 'app-about',
@@ -1,7 +1,7 @@
import {Component} from '@angular/core';
import {ScrollService} from '../scroll/scroll.service';
import {ScrollService} from '@services/scroll.service';
import {CommonModule} from '@angular/common';
import {HeaderComponent} from '../header/header.component';
import {HeaderComponent} from '@header/header.component';
@Component({
selector: 'app-birthdays',
@@ -1,7 +1,7 @@
import {Component} from '@angular/core';
import {ScrollService} from '../scroll/scroll.service';
import {ScrollService} from '@services/scroll.service';
import {CommonModule, NgOptimizedImage} from '@angular/common';
import {HeaderComponent} from '../header/header.component';
import {HeaderComponent} from '@header/header.component';
@Component({
selector: 'app-socials',
@@ -1,11 +1,11 @@
import {Component} from '@angular/core';
import {ScrollService} from '../scroll/scroll.service';
import {BASE_PATH, Player, TeamService} from '../../api';
import {ScrollService} from '@services/scroll.service';
import {BASE_PATH, Player, TeamService} from '@api';
import {CommonModule, NgOptimizedImage} from '@angular/common';
import {HeaderComponent} from '../header/header.component';
import {HeaderComponent} from '@header/header.component';
import {CookieService} from 'ngx-cookie-service';
import {map, Observable, shareReplay} from 'rxjs';
import {environment} from '../../environments/environment';
import {environment} from '@environment';
@Component({
selector: 'app-team',
@@ -1,5 +1,5 @@
import {Component} from '@angular/core';
import {HeaderComponent} from '../header/header.component';
import {HeaderComponent} from '@header/header.component';
import {NgOptimizedImage} from '@angular/common';
import {RouterLink} from '@angular/router';
@@ -1,5 +1,5 @@
import {Component} from '@angular/core';
import {HeaderComponent} from "../header/header.component";
import {HeaderComponent} from "@header/header.component";
import {RouterLink} from '@angular/router';
@Component({
@@ -1,5 +1,5 @@
import {Component} from '@angular/core';
import {HeaderComponent} from '../header/header.component';
import {HeaderComponent} from '@header/header.component';
import {NgOptimizedImage} from '@angular/common';
@Component({
@@ -1,6 +1,6 @@
import {Component} from '@angular/core';
import {CommonModule} from '@angular/common';
import {HeaderComponent} from '../header/header.component';
import {HeaderComponent} from '@header/header.component';
@Component({
standalone: true,
@@ -1,5 +1,5 @@
import {Component} from '@angular/core';
import {HeaderComponent} from '../header/header.component';
import {HeaderComponent} from '@header/header.component';
import {NgOptimizedImage} from '@angular/common';
@Component({
@@ -1,5 +1,5 @@
import {Component} from '@angular/core';
import {HeaderComponent} from '../header/header.component';
import {HeaderComponent} from '@header/header.component';
@Component({
selector: 'app-skyblock',
@@ -1,5 +1,5 @@
import {Component} from '@angular/core';
import {HeaderComponent} from '../header/header.component';
import {HeaderComponent} from '@header/header.component';
import {NgOptimizedImage} from '@angular/common';
import {RouterLink} from '@angular/router';
@@ -1,5 +1,5 @@
import {Component} from '@angular/core';
import {ALTITUDE_VERSION} from '../constant';
import {ALTITUDE_VERSION} from '@custom-types/constant';
import {CommonModule, NgOptimizedImage} from '@angular/common';
import {RouterLink} from '@angular/router';
@@ -1,7 +1,7 @@
import {Component} from '@angular/core';
import {ScrollService} from '../scroll/scroll.service';
import {ScrollService} from '@services/scroll.service';
import {CommonModule} from '@angular/common';
import {HeaderComponent} from '../header/header.component';
import {HeaderComponent} from '@header/header.component';
import {RouterLink} from '@angular/router';
@Component({
@@ -1,6 +1,6 @@
import {Component} from '@angular/core';
import {ScrollService} from '../scroll/scroll.service';
import {HeaderComponent} from '../header/header.component';
import {ScrollService} from '@services/scroll.service';
import {HeaderComponent} from '@header/header.component';
import {CommonModule} from '@angular/common';
import {RouterLink} from '@angular/router';
@@ -1,7 +1,7 @@
import {Component, OnInit} from '@angular/core';
import {FormsComponent} from '../forms.component';
import {FormControl, FormGroup, Validators} from '@angular/forms';
import {AppealsService, MinecraftAppeal} from '../../../api';
import {AppealsService, MinecraftAppeal} from '@api';
@Component({
selector: 'app-appeal',
@@ -1,12 +1,12 @@
import {Component, Input, OnInit} from '@angular/core';
import {HeaderComponent} from '../header/header.component';
import {HeaderComponent} from '@header/header.component';
import {MatDialog} from '@angular/material/dialog';
import {ActivatedRoute} from '@angular/router';
import {LoginDialogComponent} from '../login/login.component';
import {LoginDialogComponent} from '@shared-components/login/login.component';
import {KeyValuePipe, NgForOf, NgIf} from '@angular/common';
import {FormType} from './form_type';
import {MatButton} from '@angular/material/button';
import {AuthService} from '../services/auth.service';
import {AuthService} from '@services/auth.service';
@Component({
selector: 'app-forms',
@@ -130,7 +130,19 @@
<li class="nav_li"><a class="nav_link2" target="_blank" href="https://alttd.com/blog/">Blog</a></li>
</ul>
</li>
<li class="nav_li" *ngIf="isAuthenticated">
<a [id]="getCurrentPageId(['particles'])"
class="nav_link fake_link" [ngClass]="active">Special</a>
<ul class="dropdown" *ngIf="hasAccess(['HEAD_MOD'])">
<li class="nav_li"><a class="nav_link2" [routerLink]="['/particles']">Particles</a></li>
</ul>
</li>
</ul>
<ng-container *ngIf="!isAuthenticated">
<button mat-button (click)="openLoginDialog()" style="color: white">
Login
</button>
</ng-container>
<app-theme></app-theme>
</div>
</nav>
@@ -1,7 +1,12 @@
import {Component, HostListener, Input} from '@angular/core';
import {Component, HostListener, Input, OnDestroy, OnInit} from '@angular/core';
import {CommonModule, NgOptimizedImage} from '@angular/common';
import {ThemeComponent} from '../theme/theme.component';
import {ThemeComponent} from '@shared-components/theme/theme.component';
import {RouterLink} from '@angular/router';
import {AuthService} from '@services/auth.service';
import {Subscription} from 'rxjs';
import {LoginDialogComponent} from '@shared-components/login/login.component';
import {MatButton} from '@angular/material/button';
import {MatDialog} from '@angular/material/dialog';
@Component({
standalone: true,
@@ -9,13 +14,14 @@ import {RouterLink} from '@angular/router';
CommonModule,
ThemeComponent,
RouterLink,
NgOptimizedImage
NgOptimizedImage,
MatButton
],
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
})
export class HeaderComponent {
export class HeaderComponent implements OnInit, OnDestroy {
@Input() current_page: string = '';
@Input() background_image: string = '';
@Input() height: string = '';
@@ -23,6 +29,24 @@ export class HeaderComponent {
public active: string = '';
public inverseYPos: number = 0;
private subscription: Subscription | undefined;
public isAuthenticated: boolean = false;
constructor(protected authService: AuthService,
private dialog: MatDialog) {
}
ngOnInit(): void {
this.subscription = this.authService.isAuthenticated$.subscribe(isAuthenticated => {
this.isAuthenticated = isAuthenticated;
}
);
}
ngOnDestroy(): void {
this.subscription?.unsubscribe();
}
@HostListener('window:scroll', [])
onWindowScroll(): void {
@@ -59,4 +83,17 @@ export class HeaderComponent {
return '';
}
}
public openLoginDialog() {
const dialogRef = this.dialog.open(LoginDialogComponent, {
width: '400px',
})
dialogRef.afterClosed().subscribe(result => {
console.log(result);
});
}
public hasAccess(claims: string[]): boolean {
return this.authService.hasAccess(claims)
}
}
@@ -1,10 +1,10 @@
import {Component, OnInit} from '@angular/core';
import {Title} from '@angular/platform-browser';
import {ALTITUDE_VERSION} from '../constant';
import {ScrollService} from '../scroll/scroll.service';
import {ALTITUDE_VERSION} from '@custom-types/constant';
import {ScrollService} from '@services/scroll.service';
import {CommonModule, NgOptimizedImage} from '@angular/common';
import {HeaderComponent} from '../header/header.component';
import {CopyIpComponent} from '../copy-ip/copy-ip.component';
import {HeaderComponent} from '@header/header.component';
import {CopyIpComponent} from '@shared-components/copy-ip/copy-ip.component';
import {RouterLink} from '@angular/router';
@Component({
@@ -0,0 +1,48 @@
<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>
</mat-tab>
</mat-tab-group>
<div class="add-frame">
<button mat-raised-button color="primary" (click)="addFrame()">
Add New Frame
</button>
</div>
</div>
</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);
}

Some files were not shown because too many files have changed in this diff Show More