510 lines
18 KiB
TypeScript
510 lines
18 KiB
TypeScript
|
|
import { Component, inject, signal, OnInit, computed } from '@angular/core';
|
||
|
|
import { FormsModule } from '@angular/forms';
|
||
|
|
import { RouterLink } from '@angular/router';
|
||
|
|
import { ApiService } from '../../core/services/api.service';
|
||
|
|
import { ToastService } from '../../core/services/toast.service';
|
||
|
|
import { IRegistryToken, ICreateTokenRequest, IService } from '../../core/types/api.types';
|
||
|
|
import {
|
||
|
|
CardComponent,
|
||
|
|
CardHeaderComponent,
|
||
|
|
CardTitleComponent,
|
||
|
|
CardDescriptionComponent,
|
||
|
|
CardContentComponent,
|
||
|
|
} from '../../ui/card/card.component';
|
||
|
|
import { ButtonComponent } from '../../ui/button/button.component';
|
||
|
|
import { InputComponent } from '../../ui/input/input.component';
|
||
|
|
import { LabelComponent } from '../../ui/label/label.component';
|
||
|
|
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||
|
|
import {
|
||
|
|
TableComponent,
|
||
|
|
TableHeaderComponent,
|
||
|
|
TableBodyComponent,
|
||
|
|
TableRowComponent,
|
||
|
|
TableHeadComponent,
|
||
|
|
TableCellComponent,
|
||
|
|
} from '../../ui/table/table.component';
|
||
|
|
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||
|
|
import {
|
||
|
|
DialogComponent,
|
||
|
|
DialogHeaderComponent,
|
||
|
|
DialogTitleComponent,
|
||
|
|
DialogDescriptionComponent,
|
||
|
|
DialogFooterComponent,
|
||
|
|
} from '../../ui/dialog/dialog.component';
|
||
|
|
|
||
|
|
@Component({
|
||
|
|
selector: 'app-tokens',
|
||
|
|
standalone: true,
|
||
|
|
imports: [
|
||
|
|
FormsModule,
|
||
|
|
RouterLink,
|
||
|
|
CardComponent,
|
||
|
|
CardHeaderComponent,
|
||
|
|
CardTitleComponent,
|
||
|
|
CardDescriptionComponent,
|
||
|
|
CardContentComponent,
|
||
|
|
ButtonComponent,
|
||
|
|
InputComponent,
|
||
|
|
LabelComponent,
|
||
|
|
BadgeComponent,
|
||
|
|
TableComponent,
|
||
|
|
TableHeaderComponent,
|
||
|
|
TableBodyComponent,
|
||
|
|
TableRowComponent,
|
||
|
|
TableHeadComponent,
|
||
|
|
TableCellComponent,
|
||
|
|
SkeletonComponent,
|
||
|
|
DialogComponent,
|
||
|
|
DialogHeaderComponent,
|
||
|
|
DialogTitleComponent,
|
||
|
|
DialogDescriptionComponent,
|
||
|
|
DialogFooterComponent,
|
||
|
|
],
|
||
|
|
template: `
|
||
|
|
<div class="space-y-6">
|
||
|
|
<div class="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<h1 class="text-3xl font-bold tracking-tight">Registry Tokens</h1>
|
||
|
|
<p class="text-muted-foreground">Manage authentication tokens for the Onebox registry</p>
|
||
|
|
</div>
|
||
|
|
<button uiButton (click)="openCreateDialog()">Create Token</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Global Tokens Section -->
|
||
|
|
<ui-card>
|
||
|
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||
|
|
<ui-card-title>Global Tokens</ui-card-title>
|
||
|
|
<ui-card-description>Tokens that can push images to multiple services</ui-card-description>
|
||
|
|
</ui-card-header>
|
||
|
|
<ui-card-content class="p-0">
|
||
|
|
@if (loading() && globalTokens().length === 0) {
|
||
|
|
<div class="p-6 space-y-4">
|
||
|
|
@for (_ of [1,2]; track $index) {
|
||
|
|
<ui-skeleton class="h-12 w-full" />
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
} @else if (globalTokens().length === 0) {
|
||
|
|
<div class="p-12 text-center">
|
||
|
|
<p class="text-muted-foreground">No global tokens created</p>
|
||
|
|
<button uiButton variant="outline" class="mt-4" (click)="openCreateDialog('global')">
|
||
|
|
Create Global Token
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
} @else {
|
||
|
|
<ui-table>
|
||
|
|
<ui-table-header>
|
||
|
|
<ui-table-row>
|
||
|
|
<ui-table-head>Name</ui-table-head>
|
||
|
|
<ui-table-head>Scope</ui-table-head>
|
||
|
|
<ui-table-head>Expires</ui-table-head>
|
||
|
|
<ui-table-head>Last Used</ui-table-head>
|
||
|
|
<ui-table-head>Created By</ui-table-head>
|
||
|
|
<ui-table-head class="text-right">Actions</ui-table-head>
|
||
|
|
</ui-table-row>
|
||
|
|
</ui-table-header>
|
||
|
|
<ui-table-body>
|
||
|
|
@for (token of globalTokens(); track token.id) {
|
||
|
|
<ui-table-row [class.opacity-50]="token.isExpired">
|
||
|
|
<ui-table-cell class="font-medium">{{ token.name }}</ui-table-cell>
|
||
|
|
<ui-table-cell>
|
||
|
|
<ui-badge variant="secondary">{{ token.scopeDisplay }}</ui-badge>
|
||
|
|
</ui-table-cell>
|
||
|
|
<ui-table-cell>
|
||
|
|
@if (token.isExpired) {
|
||
|
|
<ui-badge variant="destructive">Expired</ui-badge>
|
||
|
|
} @else if (token.expiresAt) {
|
||
|
|
{{ formatExpiry(token.expiresAt) }}
|
||
|
|
} @else {
|
||
|
|
<span class="text-muted-foreground">Never</span>
|
||
|
|
}
|
||
|
|
</ui-table-cell>
|
||
|
|
<ui-table-cell>
|
||
|
|
@if (token.lastUsedAt) {
|
||
|
|
{{ formatRelativeTime(token.lastUsedAt) }}
|
||
|
|
} @else {
|
||
|
|
<span class="text-muted-foreground">Never</span>
|
||
|
|
}
|
||
|
|
</ui-table-cell>
|
||
|
|
<ui-table-cell>{{ token.createdBy }}</ui-table-cell>
|
||
|
|
<ui-table-cell class="text-right">
|
||
|
|
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(token)">
|
||
|
|
Delete
|
||
|
|
</button>
|
||
|
|
</ui-table-cell>
|
||
|
|
</ui-table-row>
|
||
|
|
}
|
||
|
|
</ui-table-body>
|
||
|
|
</ui-table>
|
||
|
|
}
|
||
|
|
</ui-card-content>
|
||
|
|
</ui-card>
|
||
|
|
|
||
|
|
<!-- CI Tokens Section -->
|
||
|
|
<ui-card>
|
||
|
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||
|
|
<ui-card-title>CI Tokens (Service-specific)</ui-card-title>
|
||
|
|
<ui-card-description>Tokens tied to individual services for CI/CD pipelines</ui-card-description>
|
||
|
|
</ui-card-header>
|
||
|
|
<ui-card-content class="p-0">
|
||
|
|
@if (loading() && ciTokens().length === 0) {
|
||
|
|
<div class="p-6 space-y-4">
|
||
|
|
@for (_ of [1,2]; track $index) {
|
||
|
|
<ui-skeleton class="h-12 w-full" />
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
} @else if (ciTokens().length === 0) {
|
||
|
|
<div class="p-12 text-center">
|
||
|
|
<p class="text-muted-foreground">No CI tokens created</p>
|
||
|
|
<button uiButton variant="outline" class="mt-4" (click)="openCreateDialog('ci')">
|
||
|
|
Create CI Token
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
} @else {
|
||
|
|
<ui-table>
|
||
|
|
<ui-table-header>
|
||
|
|
<ui-table-row>
|
||
|
|
<ui-table-head>Name</ui-table-head>
|
||
|
|
<ui-table-head>Service</ui-table-head>
|
||
|
|
<ui-table-head>Expires</ui-table-head>
|
||
|
|
<ui-table-head>Last Used</ui-table-head>
|
||
|
|
<ui-table-head>Created By</ui-table-head>
|
||
|
|
<ui-table-head class="text-right">Actions</ui-table-head>
|
||
|
|
</ui-table-row>
|
||
|
|
</ui-table-header>
|
||
|
|
<ui-table-body>
|
||
|
|
@for (token of ciTokens(); track token.id) {
|
||
|
|
<ui-table-row [class.opacity-50]="token.isExpired">
|
||
|
|
<ui-table-cell class="font-medium">{{ token.name }}</ui-table-cell>
|
||
|
|
<ui-table-cell>{{ token.scopeDisplay }}</ui-table-cell>
|
||
|
|
<ui-table-cell>
|
||
|
|
@if (token.isExpired) {
|
||
|
|
<ui-badge variant="destructive">Expired</ui-badge>
|
||
|
|
} @else if (token.expiresAt) {
|
||
|
|
{{ formatExpiry(token.expiresAt) }}
|
||
|
|
} @else {
|
||
|
|
<span class="text-muted-foreground">Never</span>
|
||
|
|
}
|
||
|
|
</ui-table-cell>
|
||
|
|
<ui-table-cell>
|
||
|
|
@if (token.lastUsedAt) {
|
||
|
|
{{ formatRelativeTime(token.lastUsedAt) }}
|
||
|
|
} @else {
|
||
|
|
<span class="text-muted-foreground">Never</span>
|
||
|
|
}
|
||
|
|
</ui-table-cell>
|
||
|
|
<ui-table-cell>{{ token.createdBy }}</ui-table-cell>
|
||
|
|
<ui-table-cell class="text-right">
|
||
|
|
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(token)">
|
||
|
|
Delete
|
||
|
|
</button>
|
||
|
|
</ui-table-cell>
|
||
|
|
</ui-table-row>
|
||
|
|
}
|
||
|
|
</ui-table-body>
|
||
|
|
</ui-table>
|
||
|
|
}
|
||
|
|
</ui-card-content>
|
||
|
|
</ui-card>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Create Token Dialog -->
|
||
|
|
<ui-dialog [open]="createDialogOpen()" (openChange)="createDialogOpen.set($event)">
|
||
|
|
<ui-dialog-header>
|
||
|
|
<ui-dialog-title>Create Registry Token</ui-dialog-title>
|
||
|
|
<ui-dialog-description>
|
||
|
|
Create a new token for pushing images to the Onebox registry
|
||
|
|
</ui-dialog-description>
|
||
|
|
</ui-dialog-header>
|
||
|
|
<div class="grid gap-4 py-4">
|
||
|
|
<div class="space-y-2">
|
||
|
|
<label uiLabel>Token Name</label>
|
||
|
|
<input uiInput [(ngModel)]="createForm.name" placeholder="e.g., deploy-token" />
|
||
|
|
</div>
|
||
|
|
<div class="space-y-2">
|
||
|
|
<label uiLabel>Token Type</label>
|
||
|
|
<div class="flex gap-4">
|
||
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
||
|
|
<input type="radio" name="type" value="global" [(ngModel)]="createForm.type" class="accent-primary" />
|
||
|
|
<span>Global (multiple services)</span>
|
||
|
|
</label>
|
||
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
||
|
|
<input type="radio" name="type" value="ci" [(ngModel)]="createForm.type" class="accent-primary" />
|
||
|
|
<span>CI (single service)</span>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
@if (createForm.type === 'global') {
|
||
|
|
<div class="space-y-2">
|
||
|
|
<label uiLabel>Scope</label>
|
||
|
|
<div class="flex items-center gap-2 mb-2">
|
||
|
|
<input type="checkbox" id="all-services" [(ngModel)]="scopeAll" class="accent-primary" />
|
||
|
|
<label for="all-services" class="cursor-pointer">All services</label>
|
||
|
|
</div>
|
||
|
|
@if (!scopeAll) {
|
||
|
|
<div class="border rounded-md p-3 max-h-48 overflow-y-auto space-y-2">
|
||
|
|
@for (service of services(); track service.name) {
|
||
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
[checked]="selectedServices().includes(service.name)"
|
||
|
|
(change)="toggleService(service.name)"
|
||
|
|
class="accent-primary"
|
||
|
|
/>
|
||
|
|
<span>{{ service.name }}</span>
|
||
|
|
</label>
|
||
|
|
}
|
||
|
|
@if (services().length === 0) {
|
||
|
|
<p class="text-sm text-muted-foreground">No services available</p>
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
} @else {
|
||
|
|
<div class="space-y-2">
|
||
|
|
<label uiLabel>Service</label>
|
||
|
|
<select [(ngModel)]="selectedSingleService" class="w-full p-2 border rounded-md bg-background">
|
||
|
|
<option value="">Select a service</option>
|
||
|
|
@for (service of services(); track service.name) {
|
||
|
|
<option [value]="service.name">{{ service.name }}</option>
|
||
|
|
}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
<div class="space-y-2">
|
||
|
|
<label uiLabel>Expiration</label>
|
||
|
|
<select [(ngModel)]="createForm.expiresIn" class="w-full p-2 border rounded-md bg-background">
|
||
|
|
<option value="30d">30 days</option>
|
||
|
|
<option value="90d">90 days</option>
|
||
|
|
<option value="365d">365 days</option>
|
||
|
|
<option value="never">Never</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<ui-dialog-footer>
|
||
|
|
<button uiButton variant="outline" (click)="createDialogOpen.set(false)">Cancel</button>
|
||
|
|
<button uiButton (click)="createToken()" [disabled]="creating()">
|
||
|
|
@if (creating()) {
|
||
|
|
Creating...
|
||
|
|
} @else {
|
||
|
|
Create Token
|
||
|
|
}
|
||
|
|
</button>
|
||
|
|
</ui-dialog-footer>
|
||
|
|
</ui-dialog>
|
||
|
|
|
||
|
|
<!-- Token Created Dialog -->
|
||
|
|
<ui-dialog [open]="tokenCreatedDialogOpen()" (openChange)="tokenCreatedDialogOpen.set($event)">
|
||
|
|
<ui-dialog-header>
|
||
|
|
<ui-dialog-title>Token Created</ui-dialog-title>
|
||
|
|
<ui-dialog-description>
|
||
|
|
Copy this token now. You won't be able to see it again!
|
||
|
|
</ui-dialog-description>
|
||
|
|
</ui-dialog-header>
|
||
|
|
<div class="py-4">
|
||
|
|
<div class="p-4 bg-muted rounded-md font-mono text-sm break-all">
|
||
|
|
{{ createdPlainToken() }}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<ui-dialog-footer>
|
||
|
|
<button uiButton (click)="copyToken()">Copy Token</button>
|
||
|
|
<button uiButton variant="outline" (click)="tokenCreatedDialogOpen.set(false)">Done</button>
|
||
|
|
</ui-dialog-footer>
|
||
|
|
</ui-dialog>
|
||
|
|
|
||
|
|
<!-- Delete Confirmation Dialog -->
|
||
|
|
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
|
||
|
|
<ui-dialog-header>
|
||
|
|
<ui-dialog-title>Delete Token</ui-dialog-title>
|
||
|
|
<ui-dialog-description>
|
||
|
|
Are you sure you want to delete "{{ tokenToDelete()?.name }}"? This action cannot be undone.
|
||
|
|
</ui-dialog-description>
|
||
|
|
</ui-dialog-header>
|
||
|
|
<ui-dialog-footer>
|
||
|
|
<button uiButton variant="outline" (click)="deleteDialogOpen.set(false)">Cancel</button>
|
||
|
|
<button uiButton variant="destructive" (click)="deleteToken()">Delete</button>
|
||
|
|
</ui-dialog-footer>
|
||
|
|
</ui-dialog>
|
||
|
|
`,
|
||
|
|
})
|
||
|
|
export class TokensComponent implements OnInit {
|
||
|
|
private api = inject(ApiService);
|
||
|
|
private toast = inject(ToastService);
|
||
|
|
|
||
|
|
tokens = signal<IRegistryToken[]>([]);
|
||
|
|
services = signal<IService[]>([]);
|
||
|
|
loading = signal(false);
|
||
|
|
creating = signal(false);
|
||
|
|
|
||
|
|
createDialogOpen = signal(false);
|
||
|
|
tokenCreatedDialogOpen = signal(false);
|
||
|
|
deleteDialogOpen = signal(false);
|
||
|
|
tokenToDelete = signal<IRegistryToken | null>(null);
|
||
|
|
createdPlainToken = signal('');
|
||
|
|
|
||
|
|
// Form state
|
||
|
|
createForm: ICreateTokenRequest = {
|
||
|
|
name: '',
|
||
|
|
type: 'global',
|
||
|
|
scope: 'all',
|
||
|
|
expiresIn: '90d',
|
||
|
|
};
|
||
|
|
scopeAll = true;
|
||
|
|
selectedServices = signal<string[]>([]);
|
||
|
|
selectedSingleService = '';
|
||
|
|
|
||
|
|
// Computed signals for filtered tokens
|
||
|
|
globalTokens = computed(() => this.tokens().filter(t => t.type === 'global'));
|
||
|
|
ciTokens = computed(() => this.tokens().filter(t => t.type === 'ci'));
|
||
|
|
|
||
|
|
ngOnInit(): void {
|
||
|
|
this.loadTokens();
|
||
|
|
this.loadServices();
|
||
|
|
}
|
||
|
|
|
||
|
|
async loadTokens(): Promise<void> {
|
||
|
|
this.loading.set(true);
|
||
|
|
try {
|
||
|
|
const response = await this.api.getRegistryTokens();
|
||
|
|
if (response.success && response.data) {
|
||
|
|
this.tokens.set(response.data);
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
this.toast.error('Failed to load tokens');
|
||
|
|
} finally {
|
||
|
|
this.loading.set(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async loadServices(): Promise<void> {
|
||
|
|
try {
|
||
|
|
const response = await this.api.getServices();
|
||
|
|
if (response.success && response.data) {
|
||
|
|
this.services.set(response.data);
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// Silent fail - services list is optional
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
openCreateDialog(type?: 'global' | 'ci'): void {
|
||
|
|
this.createForm = {
|
||
|
|
name: '',
|
||
|
|
type: type || 'global',
|
||
|
|
scope: 'all',
|
||
|
|
expiresIn: '90d',
|
||
|
|
};
|
||
|
|
this.scopeAll = true;
|
||
|
|
this.selectedServices.set([]);
|
||
|
|
this.selectedSingleService = '';
|
||
|
|
this.createDialogOpen.set(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
toggleService(serviceName: string): void {
|
||
|
|
const current = this.selectedServices();
|
||
|
|
if (current.includes(serviceName)) {
|
||
|
|
this.selectedServices.set(current.filter(s => s !== serviceName));
|
||
|
|
} else {
|
||
|
|
this.selectedServices.set([...current, serviceName]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async createToken(): Promise<void> {
|
||
|
|
if (!this.createForm.name) {
|
||
|
|
this.toast.error('Please enter a token name');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Build scope based on type
|
||
|
|
let scope: 'all' | string[];
|
||
|
|
if (this.createForm.type === 'global') {
|
||
|
|
if (this.scopeAll) {
|
||
|
|
scope = 'all';
|
||
|
|
} else {
|
||
|
|
if (this.selectedServices().length === 0) {
|
||
|
|
this.toast.error('Please select at least one service');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
scope = this.selectedServices();
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
if (!this.selectedSingleService) {
|
||
|
|
this.toast.error('Please select a service');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
scope = [this.selectedSingleService];
|
||
|
|
}
|
||
|
|
|
||
|
|
this.creating.set(true);
|
||
|
|
try {
|
||
|
|
const response = await this.api.createRegistryToken({
|
||
|
|
...this.createForm,
|
||
|
|
scope,
|
||
|
|
});
|
||
|
|
if (response.success && response.data) {
|
||
|
|
this.createdPlainToken.set(response.data.plainToken);
|
||
|
|
this.createDialogOpen.set(false);
|
||
|
|
this.tokenCreatedDialogOpen.set(true);
|
||
|
|
this.loadTokens();
|
||
|
|
} else {
|
||
|
|
this.toast.error(response.error || 'Failed to create token');
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
this.toast.error('Failed to create token');
|
||
|
|
} finally {
|
||
|
|
this.creating.set(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
copyToken(): void {
|
||
|
|
navigator.clipboard.writeText(this.createdPlainToken());
|
||
|
|
this.toast.success('Token copied to clipboard');
|
||
|
|
}
|
||
|
|
|
||
|
|
confirmDelete(token: IRegistryToken): void {
|
||
|
|
this.tokenToDelete.set(token);
|
||
|
|
this.deleteDialogOpen.set(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
async deleteToken(): Promise<void> {
|
||
|
|
const token = this.tokenToDelete();
|
||
|
|
if (!token) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await this.api.deleteRegistryToken(token.id);
|
||
|
|
if (response.success) {
|
||
|
|
this.toast.success('Token deleted');
|
||
|
|
this.loadTokens();
|
||
|
|
} else {
|
||
|
|
this.toast.error(response.error || 'Failed to delete token');
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
this.toast.error('Failed to delete token');
|
||
|
|
} finally {
|
||
|
|
this.deleteDialogOpen.set(false);
|
||
|
|
this.tokenToDelete.set(null);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
formatExpiry(timestamp: number): string {
|
||
|
|
const days = Math.ceil((timestamp - Date.now()) / (1000 * 60 * 60 * 24));
|
||
|
|
if (days < 0) return 'Expired';
|
||
|
|
if (days === 0) return 'Today';
|
||
|
|
if (days === 1) return 'Tomorrow';
|
||
|
|
if (days < 30) return `${days} days`;
|
||
|
|
return new Date(timestamp).toLocaleDateString();
|
||
|
|
}
|
||
|
|
|
||
|
|
formatRelativeTime(timestamp: number): string {
|
||
|
|
const diff = Date.now() - timestamp;
|
||
|
|
const minutes = Math.floor(diff / (1000 * 60));
|
||
|
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||
|
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||
|
|
|
||
|
|
if (minutes < 1) return 'Just now';
|
||
|
|
if (minutes < 60) return `${minutes}m ago`;
|
||
|
|
if (hours < 24) return `${hours}h ago`;
|
||
|
|
if (days < 30) return `${days}d ago`;
|
||
|
|
return new Date(timestamp).toLocaleDateString();
|
||
|
|
}
|
||
|
|
}
|