feat(web): new style and calculator revisited
This commit is contained in:
@@ -23,7 +23,7 @@ import { CommonModule } from '@angular/common';
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
@@ -37,7 +37,7 @@ import { CommonModule } from '@angular/common';
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-brand);
|
||||
color: white;
|
||||
color: var(--color-neutral-900);
|
||||
&:hover:not(:disabled) { background-color: var(--color-brand-hover); }
|
||||
}
|
||||
|
||||
@@ -53,7 +53,8 @@ import { CommonModule } from '@angular/common';
|
||||
color: var(--color-text);
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--color-brand);
|
||||
color: var(--color-brand);
|
||||
color: var(--color-neutral-900);
|
||||
background-color: rgba(250, 207, 10, 0.1); /* Low opacity brand color */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { CommonModule } from '@angular/common';
|
||||
(drop)="onDrop($event)"
|
||||
(click)="fileInput.click()"
|
||||
>
|
||||
<input #fileInput type="file" (change)="onFileSelected($event)" hidden [accept]="accept()">
|
||||
<input #fileInput type="file" (change)="onFileSelected($event)" hidden [accept]="accept()" [multiple]="multiple()">
|
||||
|
||||
<div class="content">
|
||||
<div class="icon">
|
||||
@@ -22,9 +22,12 @@ import { CommonModule } from '@angular/common';
|
||||
</div>
|
||||
<p class="text">{{ label() }}</p>
|
||||
<p class="subtext">{{ subtext() }}</p>
|
||||
@if (fileName()) {
|
||||
<div class="file-badge">
|
||||
{{ fileName() }}
|
||||
|
||||
@if (fileNames().length > 0) {
|
||||
<div class="file-badges">
|
||||
@for (name of fileNames(); track name) {
|
||||
<div class="file-badge">{{ name }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -48,26 +51,33 @@ import { CommonModule } from '@angular/common';
|
||||
.icon { color: var(--color-brand); margin-bottom: var(--space-4); }
|
||||
.text { font-weight: 600; margin-bottom: var(--space-2); }
|
||||
.subtext { font-size: 0.875rem; color: var(--color-text-muted); }
|
||||
.file-badge {
|
||||
.file-badges {
|
||||
margin-top: var(--space-4);
|
||||
display: inline-block;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
justify-content: center;
|
||||
}
|
||||
.file-badge {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--color-neutral-200);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
color: var(--color-primary-700);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class AppDropzoneComponent {
|
||||
label = input<string>('Drop file here or click to upload');
|
||||
label = input<string>('Drop files here or click to upload');
|
||||
subtext = input<string>('Supports .stl, .obj');
|
||||
accept = input<string>('.stl,.obj');
|
||||
multiple = input<boolean>(true);
|
||||
|
||||
fileDropped = output<File>();
|
||||
filesDropped = output<File[]>();
|
||||
|
||||
isDragOver = signal(false);
|
||||
fileName = signal<string | null>(null);
|
||||
fileNames = signal<string[]>([]);
|
||||
|
||||
onDragOver(e: Event) {
|
||||
e.preventDefault();
|
||||
@@ -82,23 +92,26 @@ export class AppDropzoneComponent {
|
||||
}
|
||||
|
||||
onDrop(e: DragEvent) {
|
||||
console.log('Drop event', e);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.isDragOver.set(false);
|
||||
if (e.dataTransfer?.files.length) {
|
||||
this.handleFile(e.dataTransfer.files[0]);
|
||||
this.handleFiles(Array.from(e.dataTransfer.files));
|
||||
}
|
||||
}
|
||||
|
||||
onFileSelected(e: Event) {
|
||||
console.log('File selected', e);
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.length) {
|
||||
this.handleFile(input.files[0]);
|
||||
this.handleFiles(Array.from(input.files));
|
||||
}
|
||||
}
|
||||
|
||||
handleFile(file: File) {
|
||||
this.fileName.set(file.name);
|
||||
this.fileDropped.emit(file);
|
||||
handleFiles(files: File[]) {
|
||||
const newNames = files.map(f => f.name);
|
||||
this.fileNames.update(current => [...current, ...newNames]);
|
||||
this.filesDropped.emit(files);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import { CommonModule } from '@angular/common';
|
||||
width: 100%;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
&:focus { outline: none; border-color: var(--color-brand); box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); }
|
||||
&:focus { outline: none; border-color: var(--color-brand); box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.25); }
|
||||
&:disabled { background: var(--color-neutral-100); cursor: not-allowed; }
|
||||
}
|
||||
.error-text { color: var(--color-danger-500); font-size: 0.75rem; margin-top: var(--space-1); }
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tabs',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, TranslateModule],
|
||||
template: `
|
||||
<div class="tabs">
|
||||
@for (tab of tabs(); track tab.value) {
|
||||
@@ -12,7 +13,7 @@ import { CommonModule } from '@angular/common';
|
||||
class="tab"
|
||||
[class.active]="activeTab() === tab.value"
|
||||
(click)="selectTab(tab.value)">
|
||||
{{ tab.label }}
|
||||
{{ tab.label | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, ViewChild, SimpleChanges } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import * as THREE from 'three';
|
||||
// @ts-ignore
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader';
|
||||
// @ts-ignore
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stl-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="viewer-container" #rendererContainer>
|
||||
@if (loading) {
|
||||
<div class="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading 3D Model...</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.viewer-container {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
background: var(--color-neutral-50);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
z-index: 10;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--color-neutral-200);
|
||||
border-top-color: var(--color-brand);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
@Input() file: File | null = null;
|
||||
@ViewChild('rendererContainer', { static: true }) rendererContainer!: ElementRef;
|
||||
|
||||
private scene!: THREE.Scene;
|
||||
private camera!: THREE.PerspectiveCamera;
|
||||
private renderer!: THREE.WebGLRenderer;
|
||||
private controls!: OrbitControls;
|
||||
private animationId: number | null = null;
|
||||
private currentMesh: THREE.Mesh | null = null;
|
||||
|
||||
loading = false;
|
||||
|
||||
ngOnInit() {
|
||||
this.initThree();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes['file'] && this.file) {
|
||||
this.loadFile(this.file);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.animationId) cancelAnimationFrame(this.animationId);
|
||||
if (this.renderer) this.renderer.dispose();
|
||||
}
|
||||
|
||||
private initThree() {
|
||||
const width = this.rendererContainer.nativeElement.clientWidth;
|
||||
const height = this.rendererContainer.nativeElement.clientHeight;
|
||||
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0xf7f6f2); // Neutral-50
|
||||
|
||||
// Lights
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
this.scene.add(ambientLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
directionalLight.position.set(1, 1, 1);
|
||||
this.scene.add(directionalLight);
|
||||
|
||||
// Camera
|
||||
this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
|
||||
this.camera.position.z = 100;
|
||||
|
||||
// Renderer
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
this.renderer.setSize(width, height);
|
||||
this.rendererContainer.nativeElement.appendChild(this.renderer.domElement);
|
||||
|
||||
// Controls
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableDamping = true;
|
||||
|
||||
this.animate();
|
||||
|
||||
// Handle resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (!this.rendererContainer) return;
|
||||
const w = this.rendererContainer.nativeElement.clientWidth;
|
||||
const h = this.rendererContainer.nativeElement.clientHeight;
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(w, h);
|
||||
});
|
||||
resizeObserver.observe(this.rendererContainer.nativeElement);
|
||||
}
|
||||
|
||||
private loadFile(file: File) {
|
||||
this.loading = true;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const loader = new STLLoader();
|
||||
const geometry = loader.parse(event.target?.result as ArrayBuffer);
|
||||
|
||||
if (this.currentMesh) {
|
||||
this.scene.remove(this.currentMesh);
|
||||
this.currentMesh.geometry.dispose();
|
||||
}
|
||||
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
color: 0xFACF0A, // Brand color
|
||||
specular: 0x111111,
|
||||
shininess: 200
|
||||
});
|
||||
|
||||
this.currentMesh = new THREE.Mesh(geometry, material);
|
||||
|
||||
// Center geometry
|
||||
geometry.computeBoundingBox();
|
||||
geometry.center();
|
||||
|
||||
// Rotate to stand upright (usually necessary for STLs)
|
||||
this.currentMesh.rotation.x = -Math.PI / 2;
|
||||
|
||||
this.scene.add(this.currentMesh);
|
||||
|
||||
// Adjust camera to fit object
|
||||
const boundingBox = geometry.boundingBox!;
|
||||
const size = new THREE.Vector3();
|
||||
boundingBox.getSize(size);
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const fov = this.camera.fov * (Math.PI / 180);
|
||||
let cameraZ = Math.abs(maxDim / 2 * Math.tan(fov * 2)); // Basic fit
|
||||
cameraZ *= 2.5; // Zoom out a bit
|
||||
|
||||
this.camera.position.z = cameraZ;
|
||||
this.camera.updateProjectionMatrix();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading STL:', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
|
||||
private animate() {
|
||||
this.animationId = requestAnimationFrame(() => this.animate());
|
||||
if (this.controls) this.controls.update();
|
||||
if (this.renderer && this.scene && this.camera) {
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-summary-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="summary-card" [class.highlight]="highlight()">
|
||||
<span class="label">{{ label() }}</span>
|
||||
<span class="value" [class.large]="large()">
|
||||
<ng-content></ng-content>
|
||||
</span>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.summary-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.highlight {
|
||||
background: var(--color-neutral-100);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.large {
|
||||
font-size: 2rem;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SummaryCardComponent {
|
||||
label = input.required<string>();
|
||||
highlight = input<boolean>(false);
|
||||
large = input<boolean>(false);
|
||||
}
|
||||
Reference in New Issue
Block a user