Implement foundational vault data models, service, and shared UI components.
This commit is contained in:
69
package-lock.json
generated
69
package-lock.json
generated
@@ -26,9 +26,12 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tauri-apps/cli": "^2.10.1",
|
"@tauri-apps/cli": "^2.10.1",
|
||||||
"angular-eslint": "21.3.1",
|
"angular-eslint": "21.3.1",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
"eslint": "^10.0.3",
|
"eslint": "^10.0.3",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "8.56.1",
|
"typescript-eslint": "8.56.1",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
@@ -5475,6 +5478,43 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/autoprefixer": {
|
||||||
|
"version": "10.4.27",
|
||||||
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
||||||
|
"integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"browserslist": "^4.28.1",
|
||||||
|
"caniuse-lite": "^1.0.30001774",
|
||||||
|
"fraction.js": "^5.3.4",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"postcss-value-parser": "^4.2.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"autoprefixer": "bin/autoprefixer"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"postcss": "^8.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -6874,6 +6914,20 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fraction.js": {
|
||||||
|
"version": "5.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
|
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/rawify"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fresh": {
|
"node_modules/fresh": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
@@ -8806,6 +8860,13 @@
|
|||||||
"postcss": "^8.4.31"
|
"postcss": "^8.4.31"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-value-parser": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -9593,6 +9654,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwindcss": {
|
||||||
|
"version": "4.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||||
|
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.12",
|
"version": "7.5.12",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.12.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.12.tgz",
|
||||||
|
|||||||
@@ -30,9 +30,12 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tauri-apps/cli": "^2.10.1",
|
"@tauri-apps/cli": "^2.10.1",
|
||||||
"angular-eslint": "21.3.1",
|
"angular-eslint": "21.3.1",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
"eslint": "^10.0.3",
|
"eslint": "^10.0.3",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "8.56.1",
|
"typescript-eslint": "8.56.1",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description = "Cross-platform password manager built with Tauri and Angular"
|
|||||||
authors = ["stopmnenepriyatno"]
|
authors = ["stopmnenepriyatno"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/stopmnenepriyatno/abdristus"
|
repository = "https://github.com/stopmnenepriyatno/abdristus"
|
||||||
edition = "2024"
|
edition = "2026"
|
||||||
rust-version = "1.94.0"
|
rust-version = "1.94.0"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|||||||
61
src/app/core/services/vault.service.ts
Normal file
61
src/app/core/services/vault.service.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
import { Account } from '../../models/account.model';
|
||||||
|
import { AccountDTO } from '../../models/account.dto';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class VaultService {
|
||||||
|
private _accounts = new BehaviorSubject<Account[]>([]);
|
||||||
|
public accounts$: Observable<Account[]> = this._accounts.asObservable();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Initial mock data to simulate DB
|
||||||
|
this.loadMockData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the current session data when the system is locked.
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this._accounts.next([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAccountsSnapshot(): Account[] {
|
||||||
|
return this._accounts.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper mock methods for UI dev (normally IPC commands)
|
||||||
|
private loadMockData(): void {
|
||||||
|
const mockDTOs: AccountDTO[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
service_name: 'Google',
|
||||||
|
encrypted_data: 'enc_123',
|
||||||
|
nonce: 'n_123',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
is_deleted: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
service_name: 'GitHub',
|
||||||
|
encrypted_data: 'enc_456',
|
||||||
|
nonce: 'n_456',
|
||||||
|
updated_at: new Date(Date.now() - 86400000).toISOString(),
|
||||||
|
is_deleted: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockAccounts = mockDTOs.map(dto => {
|
||||||
|
const account = new Account(dto, {
|
||||||
|
login: `user@${dto.service_name.toLowerCase()}.com`,
|
||||||
|
password: 'mock_password_123',
|
||||||
|
notes: 'Mock data for UI dev'
|
||||||
|
});
|
||||||
|
return account;
|
||||||
|
});
|
||||||
|
|
||||||
|
this._accounts.next(mockAccounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/app/models/account.dto.ts
Normal file
8
src/app/models/account.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface AccountDTO {
|
||||||
|
id: string;
|
||||||
|
service_name: string;
|
||||||
|
encrypted_data: string; // The encrypted JSON chunk with login/password
|
||||||
|
nonce: string;
|
||||||
|
updated_at: string; // ISO date string
|
||||||
|
is_deleted: boolean;
|
||||||
|
}
|
||||||
50
src/app/models/account.model.ts
Normal file
50
src/app/models/account.model.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { AccountDTO } from './account.dto';
|
||||||
|
import { AccountVisitor } from './visitor.interface';
|
||||||
|
|
||||||
|
export class Account {
|
||||||
|
private _dto: AccountDTO;
|
||||||
|
private _decryptedData: Record<string, string | undefined>; // Could be typed further (e.g., { login, password, notes, url })
|
||||||
|
|
||||||
|
constructor(dto: AccountDTO, decryptedData?: Record<string, string | undefined>) {
|
||||||
|
this._dto = dto;
|
||||||
|
this._decryptedData = decryptedData || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): string {
|
||||||
|
return this._dto.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get serviceName(): string {
|
||||||
|
return this._dto.service_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get updatedAt(): Date {
|
||||||
|
return new Date(this._dto.updated_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isDeleted(): boolean {
|
||||||
|
return this._dto.is_deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
get decryptedData(): Record<string, string | undefined> {
|
||||||
|
return this._decryptedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
set decryptedData(data: Record<string, string | undefined>) {
|
||||||
|
this._decryptedData = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if the account currently has required fields decrypted (e.g., login, password).
|
||||||
|
*/
|
||||||
|
isValid(): boolean {
|
||||||
|
return !!this._decryptedData?.['login'] && !!this._decryptedData?.['password'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the Visitor pattern for operations like export.
|
||||||
|
*/
|
||||||
|
accept(visitor: AccountVisitor): void {
|
||||||
|
visitor.visitAccount(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/models/csv-export.visitor.ts
Normal file
33
src/app/models/csv-export.visitor.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Account } from './account.model';
|
||||||
|
import { AccountVisitor } from './visitor.interface';
|
||||||
|
|
||||||
|
export class CsvExportVisitor implements AccountVisitor {
|
||||||
|
private rows: string[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Add CSV header
|
||||||
|
this.rows.push('Service,Login,Password,Notes,UpdatedAt');
|
||||||
|
}
|
||||||
|
|
||||||
|
visitAccount(account: Account): void {
|
||||||
|
const data = account.decryptedData || {};
|
||||||
|
const service = this.escapeCsv(account.serviceName);
|
||||||
|
const login = this.escapeCsv(data['login'] || '');
|
||||||
|
const password = this.escapeCsv(data['password'] || '');
|
||||||
|
const notes = this.escapeCsv(data['notes'] || '');
|
||||||
|
const updatedAt = account.updatedAt.toISOString();
|
||||||
|
|
||||||
|
this.rows.push(`${service},${login},${password},${notes},${updatedAt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCsv(): string {
|
||||||
|
return this.rows.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeCsv(value: string): string {
|
||||||
|
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/app/models/user.model.ts
Normal file
18
src/app/models/user.model.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export class User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
settings: {
|
||||||
|
autoLockTimer: number; // in minutes
|
||||||
|
theme: 'system' | 'dark' | 'light';
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(id: string, name: string) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.settings = {
|
||||||
|
autoLockTimer: 5,
|
||||||
|
theme: 'dark' // Monolithic Vault default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/app/models/visitor.interface.ts
Normal file
5
src/app/models/visitor.interface.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { Account } from './account.model';
|
||||||
|
|
||||||
|
export interface AccountVisitor {
|
||||||
|
visitAccount(account: Account): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Account } from '../../../models/account.model';
|
||||||
|
import { ButtonComponent } from '../button/button.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-account-card',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ButtonComponent],
|
||||||
|
template: `
|
||||||
|
<div class="bg-[var(--color-surface-container-highest)] ghost-border rounded-sm p-4 hover:bg-[color-mix(in_srgb,var(--color-surface-bright)_10%,transparent)] transition-colors group flex items-center justify-between">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<h3 class="text-[var(--color-on-surface)] font-inter font-medium text-lg m-0">{{ account.serviceName }}</h3>
|
||||||
|
@if (account.decryptedData?.['login']) {
|
||||||
|
<p class="text-[var(--color-on-surface-variant)] font-jetbrains text-sm m-0 mt-1">
|
||||||
|
{{ account.decryptedData?.['login'] }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
|
||||||
|
<app-button variant="secondary" size="sm" (clicked)="copyClicked.emit(account)">Copy</app-button>
|
||||||
|
<app-button variant="secondary" size="sm" (clicked)="editClicked.emit(account)">Edit</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class AccountCardComponent {
|
||||||
|
@Input() account!: Account;
|
||||||
|
@Output() copyClicked = new EventEmitter<Account>();
|
||||||
|
@Output() editClicked = new EventEmitter<Account>();
|
||||||
|
}
|
||||||
49
src/app/shared/components/button/button.component.ts
Normal file
49
src/app/shared/components/button/button.component.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-button',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<button
|
||||||
|
[type]="type"
|
||||||
|
[disabled]="disabled"
|
||||||
|
(click)="clicked.emit($event)"
|
||||||
|
[ngClass]="[
|
||||||
|
'transition-all duration-200 ease-in-out font-inter font-medium flex items-center justify-center cursor-pointer',
|
||||||
|
baseClasses,
|
||||||
|
variantClasses[variant]
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ButtonComponent {
|
||||||
|
@Input() type: 'button' | 'submit' | 'reset' = 'button';
|
||||||
|
@Input() variant: 'primary' | 'secondary' | 'tertiary' = 'primary';
|
||||||
|
@Input() disabled = false;
|
||||||
|
@Input() size: 'sm' | 'md' | 'lg' = 'md';
|
||||||
|
@Output() clicked = new EventEmitter<MouseEvent>();
|
||||||
|
|
||||||
|
get baseClasses(): string {
|
||||||
|
switch (this.size) {
|
||||||
|
case 'sm':
|
||||||
|
return 'px-3 py-1.5 text-sm rounded-sm';
|
||||||
|
case 'lg':
|
||||||
|
return 'px-6 py-3 text-lg rounded-[0.125rem]';
|
||||||
|
case 'md':
|
||||||
|
default:
|
||||||
|
return 'px-4 py-2 text-base rounded-[0.125rem]'; // sm roundedness from DESIGN.md
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get variantClasses(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
primary: 'bg-[var(--color-primary-container)] text-[var(--color-on-primary-container)] hover:bg-opacity-90 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
secondary: 'bg-transparent text-[var(--color-on-surface)] ghost-border hover:bg-[var(--color-surface-bright)] hover:bg-opacity-10',
|
||||||
|
tertiary: 'bg-transparent text-[var(--color-primary)] hover:underline px-0 py-0'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-notification',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
@if (visible) {
|
||||||
|
<div class="fixed bottom-4 right-4 z-50 glass-panel ghost-border rounded-sm px-4 py-3 min-w-[300px] flex items-start gap-3 shadow-lg transition-all duration-300"
|
||||||
|
[ngClass]="type === 'error' ? 'border-red-500/50' : 'border-[var(--color-primary)]/50'">
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<div [ngClass]="type === 'error' ? 'text-red-400' : 'text-[var(--color-primary)]'" class="mt-0.5">
|
||||||
|
@if (type === 'success') {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
@if (type === 'error') {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="text-[var(--color-on-surface)] font-inter font-medium text-sm m-0">{{ title }}</h4>
|
||||||
|
<p class="text-[var(--color-on-surface-variant)] font-inter text-sm m-0 mt-0.5">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Close -->
|
||||||
|
<button (click)="close()" class="text-[var(--color-outline-variant)] hover:text-[var(--color-on-surface)] transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class NotificationComponent implements OnInit {
|
||||||
|
@Input() title = '';
|
||||||
|
@Input() message = '';
|
||||||
|
@Input() type: 'success' | 'error' = 'success';
|
||||||
|
@Input() duration = 5000;
|
||||||
|
|
||||||
|
visible = true;
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (this.duration > 0) {
|
||||||
|
setTimeout(() => this.close(), this.duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Component, Input, forwardRef } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-password-input',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: forwardRef(() => PasswordInputComponent),
|
||||||
|
multi: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="flex flex-col gap-1 w-full relative">
|
||||||
|
<div class="relative w-full">
|
||||||
|
<input
|
||||||
|
[type]="showPassword ? 'text' : 'password'"
|
||||||
|
[placeholder]="placeholder"
|
||||||
|
[disabled]="disabled"
|
||||||
|
[(ngModel)]="value"
|
||||||
|
(ngModelChange)="onModelChange($event)"
|
||||||
|
(blur)="onModelTouched()"
|
||||||
|
class="w-full bg-[var(--color-surface-container-lowest)] text-[var(--color-on-surface)]
|
||||||
|
placeholder:text-[var(--color-outline-variant)] border border-[color-mix(in_srgb,var(--color-outline-variant)_30%,transparent)]
|
||||||
|
rounded-sm px-3 py-2 font-jetbrains text-sm focus:outline-none focus:border-[var(--color-primary)] transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="togglePassword()"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-outline-variant)] hover:text-[var(--color-on-surface)] transition-colors"
|
||||||
|
>
|
||||||
|
<!-- Heroicon Eye/EyeSlash simple svg -->
|
||||||
|
@if (!showPassword) {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
@if (showPassword) {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showIndicator) {
|
||||||
|
<div class="flex gap-1 mt-1">
|
||||||
|
<div class="h-1 flex-1 rounded-full transition-all duration-300" [ngClass]="strengthScore >= 1 ? 'bg-red-500' : 'bg-[var(--color-surface-container-highest)]'"></div>
|
||||||
|
<div class="h-1 flex-1 rounded-full transition-all duration-300" [ngClass]="strengthScore >= 2 ? 'bg-yellow-500' : 'bg-[var(--color-surface-container-highest)]'"></div>
|
||||||
|
<div class="h-1 flex-1 rounded-full transition-all duration-300" [ngClass]="strengthScore >= 3 ? 'bg-green-500' : 'bg-[var(--color-surface-container-highest)]'"></div>
|
||||||
|
@if (strengthScore > 0) {
|
||||||
|
<span class="text-xs text-[var(--color-outline-variant)] ml-2 uppercase font-jetbrains">
|
||||||
|
{{ strengthLabel }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class PasswordInputComponent implements ControlValueAccessor {
|
||||||
|
@Input() placeholder = 'Password';
|
||||||
|
@Input() showIndicator = true;
|
||||||
|
@Input() disabled = false;
|
||||||
|
|
||||||
|
showPassword = false;
|
||||||
|
value = '';
|
||||||
|
strengthScore = 0;
|
||||||
|
strengthLabel = '';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
onChange = (_val: string) => { /* default noop */ };
|
||||||
|
onTouched = () => { /* default noop */ };
|
||||||
|
|
||||||
|
togglePassword() {
|
||||||
|
this.showPassword = !this.showPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
onModelChange(val: string) {
|
||||||
|
this.value = val;
|
||||||
|
this.calculateStrength(val);
|
||||||
|
this.onChange(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModelTouched() {
|
||||||
|
this.onTouched();
|
||||||
|
}
|
||||||
|
|
||||||
|
writeValue(val: string): void {
|
||||||
|
if (val !== undefined) {
|
||||||
|
this.value = val;
|
||||||
|
this.calculateStrength(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnChange(fn: (val: string) => void): void {
|
||||||
|
this.onChange = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnTouched(fn: () => void): void {
|
||||||
|
this.onTouched = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisabledState(isDisabled: boolean): void {
|
||||||
|
this.disabled = isDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateStrength(password: string) {
|
||||||
|
if (!password) {
|
||||||
|
this.strengthScore = 0;
|
||||||
|
this.strengthLabel = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
if (password.length >= 8) score++;
|
||||||
|
if (/[A-Z]/.test(password) && /[0-9]/.test(password)) score++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(password)) score++;
|
||||||
|
|
||||||
|
this.strengthScore = Math.min(score, 3);
|
||||||
|
if (this.strengthScore === 1) this.strengthLabel = 'weak';
|
||||||
|
else if (this.strengthScore === 2) this.strengthLabel = 'fair';
|
||||||
|
else if (this.strengthScore >= 3) this.strengthLabel = 'strong';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@
|
|||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<!-- Google Fonts: Inter and JetBrains Mono -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|||||||
@@ -1 +1,55 @@
|
|||||||
/* You can add global styles to this file, and also import other style files */
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-surface: #131313;
|
||||||
|
--color-surface-container-lowest: #0e0e0e;
|
||||||
|
--color-surface-container: #201f1f;
|
||||||
|
--color-surface-container-highest: #353534;
|
||||||
|
--color-surface-bright: rgba(255, 255, 255, 0.6);
|
||||||
|
--color-surface-low: #1c1c1c;
|
||||||
|
|
||||||
|
--color-primary: #ffb694;
|
||||||
|
--color-primary-container: #c2571a;
|
||||||
|
--color-on-primary-container: #ffffff;
|
||||||
|
|
||||||
|
--color-on-surface: #e5e2e1;
|
||||||
|
--color-on-surface-variant: #dec0b4;
|
||||||
|
--color-outline-variant: #574239;
|
||||||
|
|
||||||
|
--font-inter: "Inter", sans-serif;
|
||||||
|
--font-jetbrains: "JetBrains Mono", monospace;
|
||||||
|
|
||||||
|
/* Defaults */
|
||||||
|
--font-sans: "Inter", sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
font-family: var(--font-inter);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* The "Ghost Border" rule */
|
||||||
|
.ghost-border {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-outline-variant) 15%, transparent);
|
||||||
|
}
|
||||||
|
.ghost-border-t {
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--color-outline-variant) 15%, transparent);
|
||||||
|
}
|
||||||
|
.ghost-border-b {
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism */
|
||||||
|
.glass-panel {
|
||||||
|
background-color: color-mix(in srgb, var(--color-surface-bright) 60%, transparent);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user