feat: implement Figlet, Pastel, and Unit tools with a unified layout
- Add Figlet text converter with font selection and history - Add Pastel color palette generator and manipulation suite - Add comprehensive Units converter with category-based logic - Introduce AppShell with Sidebar and Header for navigation - Modernize theme system with CSS variables and new animations - Update project configuration and dependencies
This commit is contained in:
4
lib/units/index.ts
Normal file
4
lib/units/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './units';
|
||||
export * from './storage';
|
||||
export * from './utils';
|
||||
export * from './tempo';
|
||||
115
lib/units/storage.ts
Normal file
115
lib/units/storage.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* LocalStorage utilities for persisting user data
|
||||
*/
|
||||
|
||||
export interface ConversionRecord {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
from: {
|
||||
value: number;
|
||||
unit: string;
|
||||
};
|
||||
to: {
|
||||
value: number;
|
||||
unit: string;
|
||||
};
|
||||
measure: string;
|
||||
}
|
||||
|
||||
const HISTORY_KEY = 'units-ui-history';
|
||||
const FAVORITES_KEY = 'units-ui-favorites';
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
/**
|
||||
* Save conversion to history
|
||||
*/
|
||||
export function saveToHistory(record: Omit<ConversionRecord, 'id' | 'timestamp'>): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const history = getHistory();
|
||||
const newRecord: ConversionRecord = {
|
||||
...record,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Add to beginning and limit size
|
||||
const updated = [newRecord, ...history].slice(0, MAX_HISTORY);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(updated));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversion history
|
||||
*/
|
||||
export function getHistory(): ConversionRecord[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(HISTORY_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear conversion history
|
||||
*/
|
||||
export function clearHistory(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(HISTORY_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get favorite units
|
||||
*/
|
||||
export function getFavorites(): string[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(FAVORITES_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add unit to favorites
|
||||
*/
|
||||
export function addToFavorites(unit: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const favorites = getFavorites();
|
||||
if (!favorites.includes(unit)) {
|
||||
favorites.push(unit);
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove unit from favorites
|
||||
*/
|
||||
export function removeFromFavorites(unit: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const favorites = getFavorites();
|
||||
const filtered = favorites.filter(u => u !== unit);
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite status
|
||||
*/
|
||||
export function toggleFavorite(unit: string): boolean {
|
||||
const favorites = getFavorites();
|
||||
const isFavorite = favorites.includes(unit);
|
||||
|
||||
if (isFavorite) {
|
||||
removeFromFavorites(unit);
|
||||
return false;
|
||||
} else {
|
||||
addToFavorites(unit);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
117
lib/units/tempo.ts
Normal file
117
lib/units/tempo.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Custom tempo/BPM measure for convert-units
|
||||
*
|
||||
* Converts between BPM and note durations in milliseconds
|
||||
* Uses a reciprocal relationship where BPM (beats per minute) is the base unit
|
||||
*
|
||||
* Formula: milliseconds per beat = 60000 / BPM
|
||||
*
|
||||
* The to_anchor value represents the conversion factor:
|
||||
* - For BPM → time units: multiply by to_anchor to get milliseconds
|
||||
* - For time units → BPM: divide by to_anchor to get BPM
|
||||
*/
|
||||
|
||||
export const tempoMeasure = {
|
||||
tempo: {
|
||||
systems: {
|
||||
metric: {
|
||||
// BPM as the base unit (1 BPM = 60000 ms per beat)
|
||||
'BPM': {
|
||||
name: { singular: 'Beat per Minute', plural: 'Beats per Minute' },
|
||||
to_anchor: 1
|
||||
},
|
||||
|
||||
// Whole note (4 beats) = 240000 / BPM
|
||||
'whole': {
|
||||
name: { singular: 'Whole Note (ms)', plural: 'Whole Note (ms)' },
|
||||
to_anchor: 240000
|
||||
},
|
||||
|
||||
// Half note (2 beats) = 120000 / BPM
|
||||
'half': {
|
||||
name: { singular: 'Half Note (ms)', plural: 'Half Note (ms)' },
|
||||
to_anchor: 120000
|
||||
},
|
||||
|
||||
// Quarter note (1 beat) = 60000 / BPM
|
||||
'quarter': {
|
||||
name: { singular: 'Quarter Note (ms)', plural: 'Quarter Note (ms)' },
|
||||
to_anchor: 60000
|
||||
},
|
||||
|
||||
// Eighth note (0.5 beats) = 30000 / BPM
|
||||
'eighth': {
|
||||
name: { singular: 'Eighth Note (ms)', plural: 'Eighth Note (ms)' },
|
||||
to_anchor: 30000
|
||||
},
|
||||
|
||||
// Sixteenth note (0.25 beats) = 15000 / BPM
|
||||
'sixteenth': {
|
||||
name: { singular: 'Sixteenth Note (ms)', plural: 'Sixteenth Note (ms)' },
|
||||
to_anchor: 15000
|
||||
},
|
||||
|
||||
// Thirty-second note (0.125 beats) = 7500 / BPM
|
||||
'thirty-second': {
|
||||
name: { singular: 'Thirty-Second Note (ms)', plural: 'Thirty-Second Note (ms)' },
|
||||
to_anchor: 7500
|
||||
},
|
||||
|
||||
// Dotted notes (1.5x the duration)
|
||||
'dotted-half': {
|
||||
name: { singular: 'Dotted Half Note (ms)', plural: 'Dotted Half Note (ms)' },
|
||||
to_anchor: 180000 // 3 beats
|
||||
},
|
||||
|
||||
'dotted-quarter': {
|
||||
name: { singular: 'Dotted Quarter Note (ms)', plural: 'Dotted Quarter Note (ms)' },
|
||||
to_anchor: 90000 // 1.5 beats
|
||||
},
|
||||
|
||||
'dotted-eighth': {
|
||||
name: { singular: 'Dotted Eighth Note (ms)', plural: 'Dotted Eighth Note (ms)' },
|
||||
to_anchor: 45000 // 0.75 beats
|
||||
},
|
||||
|
||||
'dotted-sixteenth': {
|
||||
name: { singular: 'Dotted Sixteenth Note (ms)', plural: 'Dotted Sixteenth Note (ms)' },
|
||||
to_anchor: 22500 // 0.375 beats
|
||||
},
|
||||
|
||||
// Triplet notes (2/3 of the duration)
|
||||
'quarter-triplet': {
|
||||
name: { singular: 'Quarter Triplet (ms)', plural: 'Quarter Triplet (ms)' },
|
||||
to_anchor: 40000 // 2/3 beat
|
||||
},
|
||||
|
||||
'eighth-triplet': {
|
||||
name: { singular: 'Eighth Triplet (ms)', plural: 'Eighth Triplet (ms)' },
|
||||
to_anchor: 20000 // 1/3 beat
|
||||
},
|
||||
|
||||
'sixteenth-triplet': {
|
||||
name: { singular: 'Sixteenth Triplet (ms)', plural: 'Sixteenth Triplet (ms)' },
|
||||
to_anchor: 10000 // 1/6 beat
|
||||
},
|
||||
|
||||
// Milliseconds as direct time unit
|
||||
'ms': {
|
||||
name: { singular: 'Millisecond', plural: 'Milliseconds' },
|
||||
to_anchor: 60000 // Same as quarter note
|
||||
},
|
||||
|
||||
// Seconds
|
||||
's': {
|
||||
name: { singular: 'Second', plural: 'Seconds' },
|
||||
to_anchor: 60 // 60 seconds per beat at 1 BPM
|
||||
},
|
||||
|
||||
// Hertz (beats per second)
|
||||
'Hz': {
|
||||
name: { singular: 'Hertz', plural: 'Hertz' },
|
||||
to_anchor: 1 / 60 // 1 BPM = 1/60 Hz
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
306
lib/units/units.ts
Normal file
306
lib/units/units.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Unit conversion service wrapper for convert-units library
|
||||
* Provides type-safe conversion utilities and metadata
|
||||
*/
|
||||
|
||||
import convert from 'convert-units';
|
||||
import { tempoMeasure } from './tempo';
|
||||
|
||||
export type Measure =
|
||||
| 'angle'
|
||||
| 'apparentPower'
|
||||
| 'area'
|
||||
| 'current'
|
||||
| 'digital'
|
||||
| 'each'
|
||||
| 'energy'
|
||||
| 'frequency'
|
||||
| 'illuminance'
|
||||
| 'length'
|
||||
| 'mass'
|
||||
| 'pace'
|
||||
| 'partsPer'
|
||||
| 'power'
|
||||
| 'pressure'
|
||||
| 'reactiveEnergy'
|
||||
| 'reactivePower'
|
||||
| 'speed'
|
||||
| 'temperature'
|
||||
| 'tempo'
|
||||
| 'time'
|
||||
| 'voltage'
|
||||
| 'volume'
|
||||
| 'volumeFlowRate';
|
||||
|
||||
export interface UnitInfo {
|
||||
abbr: string;
|
||||
measure: Measure;
|
||||
system: 'metric' | 'imperial' | 'bits' | 'bytes' | string;
|
||||
singular: string;
|
||||
plural: string;
|
||||
}
|
||||
|
||||
export interface ConversionResult {
|
||||
value: number;
|
||||
unit: string;
|
||||
unitInfo: UnitInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available measures/categories
|
||||
*/
|
||||
export function getAllMeasures(): Measure[] {
|
||||
const standardMeasures = convert().measures() as Measure[];
|
||||
return [...standardMeasures, 'tempo'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all units for a specific measure
|
||||
*/
|
||||
export function getUnitsForMeasure(measure: Measure): string[] {
|
||||
if (measure === 'tempo') {
|
||||
return Object.keys(tempoMeasure.tempo.systems.metric);
|
||||
}
|
||||
return convert().possibilities(measure);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a unit
|
||||
*/
|
||||
export function getUnitInfo(unit: string): UnitInfo | null {
|
||||
try {
|
||||
// Check if it's a tempo unit
|
||||
const tempoUnits = tempoMeasure.tempo.systems.metric;
|
||||
if (unit in tempoUnits) {
|
||||
const tempoUnit = tempoUnits[unit as keyof typeof tempoUnits];
|
||||
return {
|
||||
abbr: unit,
|
||||
measure: 'tempo',
|
||||
system: 'metric',
|
||||
singular: tempoUnit.name.singular,
|
||||
plural: tempoUnit.name.plural,
|
||||
};
|
||||
}
|
||||
|
||||
const description = convert().describe(unit);
|
||||
return description as UnitInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value from one unit to another
|
||||
*/
|
||||
export function convertUnit(
|
||||
value: number,
|
||||
fromUnit: string,
|
||||
toUnit: string
|
||||
): number {
|
||||
try {
|
||||
// Handle tempo conversions
|
||||
const tempoUnits = tempoMeasure.tempo.systems.metric;
|
||||
const isFromTempo = fromUnit in tempoUnits;
|
||||
const isToTempo = toUnit in tempoUnits;
|
||||
|
||||
if (isFromTempo && isToTempo) {
|
||||
// Same unit - no conversion needed
|
||||
if (fromUnit === toUnit) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const fromAnchor = tempoUnits[fromUnit as keyof typeof tempoUnits].to_anchor;
|
||||
const toAnchor = tempoUnits[toUnit as keyof typeof tempoUnits].to_anchor;
|
||||
|
||||
// Special handling for BPM conversions (reciprocal relationship)
|
||||
if (fromUnit === 'BPM') {
|
||||
// BPM → time unit: divide anchor by BPM value
|
||||
// Example: 120 BPM → quarter = 60000 / 120 = 500ms
|
||||
return toAnchor / value;
|
||||
} else if (toUnit === 'BPM') {
|
||||
// Time unit → BPM: divide anchor by time value
|
||||
// Example: 500ms quarter → BPM = 60000 / 500 = 120
|
||||
return fromAnchor / value;
|
||||
} else {
|
||||
// Time unit → time unit: convert through BPM
|
||||
// Example: 500ms quarter → eighth
|
||||
// Step 1: 500ms → BPM = 60000 / 500 = 120
|
||||
// Step 2: 120 BPM → eighth = 30000 / 120 = 250ms
|
||||
const bpm = fromAnchor / value;
|
||||
return toAnchor / bpm;
|
||||
}
|
||||
}
|
||||
|
||||
return convert(value).from(fromUnit).to(toUnit);
|
||||
} catch (error) {
|
||||
console.error('Conversion error:', error);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value to all compatible units in the same measure
|
||||
*/
|
||||
export function convertToAll(
|
||||
value: number,
|
||||
fromUnit: string
|
||||
): ConversionResult[] {
|
||||
try {
|
||||
const unitInfo = getUnitInfo(fromUnit);
|
||||
if (!unitInfo) return [];
|
||||
|
||||
const compatibleUnits = getUnitsForMeasure(unitInfo.measure);
|
||||
|
||||
return compatibleUnits.map(toUnit => {
|
||||
const convertedValue = convertUnit(value, fromUnit, toUnit);
|
||||
const toUnitInfo = getUnitInfo(toUnit);
|
||||
|
||||
return {
|
||||
value: convertedValue,
|
||||
unit: toUnit,
|
||||
unitInfo: toUnitInfo!,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Conversion error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category color for a measure (Tailwind class name)
|
||||
*/
|
||||
export function getCategoryColor(measure: Measure): string {
|
||||
const colorMap: Record<Measure, string> = {
|
||||
angle: 'category-angle',
|
||||
apparentPower: 'category-apparent-power',
|
||||
area: 'category-area',
|
||||
current: 'category-current',
|
||||
digital: 'category-digital',
|
||||
each: 'category-each',
|
||||
energy: 'category-energy',
|
||||
frequency: 'category-frequency',
|
||||
illuminance: 'category-illuminance',
|
||||
length: 'category-length',
|
||||
mass: 'category-mass',
|
||||
pace: 'category-pace',
|
||||
partsPer: 'category-parts-per',
|
||||
power: 'category-power',
|
||||
pressure: 'category-pressure',
|
||||
reactiveEnergy: 'category-reactive-energy',
|
||||
reactivePower: 'category-reactive-power',
|
||||
speed: 'category-speed',
|
||||
temperature: 'category-temperature',
|
||||
tempo: 'category-tempo',
|
||||
time: 'category-time',
|
||||
voltage: 'category-voltage',
|
||||
volume: 'category-volume',
|
||||
volumeFlowRate: 'category-volume-flow-rate',
|
||||
};
|
||||
|
||||
return colorMap[measure];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category color hex value for a measure
|
||||
*/
|
||||
export function getCategoryColorHex(measure: Measure): string {
|
||||
const colorMap: Record<Measure, string> = {
|
||||
angle: '#0EA5E9',
|
||||
apparentPower: '#8B5CF6',
|
||||
area: '#F59E0B',
|
||||
current: '#F59E0B',
|
||||
digital: '#06B6D4',
|
||||
each: '#64748B',
|
||||
energy: '#EAB308',
|
||||
frequency: '#A855F7',
|
||||
illuminance: '#84CC16',
|
||||
length: '#3B82F6',
|
||||
mass: '#10B981',
|
||||
pace: '#14B8A6',
|
||||
partsPer: '#EC4899',
|
||||
power: '#F43F5E',
|
||||
pressure: '#6366F1',
|
||||
reactiveEnergy: '#D946EF',
|
||||
reactivePower: '#E879F9',
|
||||
speed: '#10B981',
|
||||
temperature: '#EF4444',
|
||||
tempo: '#F97316', // Orange for music/tempo
|
||||
time: '#7C3AED',
|
||||
voltage: '#FB923C',
|
||||
volume: '#8B5CF6',
|
||||
volumeFlowRate: '#22D3EE',
|
||||
};
|
||||
|
||||
return colorMap[measure];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format measure name for display
|
||||
*/
|
||||
export function formatMeasureName(measure: Measure): string {
|
||||
const nameMap: Record<Measure, string> = {
|
||||
angle: 'Angle',
|
||||
apparentPower: 'Apparent Power',
|
||||
area: 'Area',
|
||||
current: 'Current',
|
||||
digital: 'Digital Storage',
|
||||
each: 'Each',
|
||||
energy: 'Energy',
|
||||
frequency: 'Frequency',
|
||||
illuminance: 'Illuminance',
|
||||
length: 'Length',
|
||||
mass: 'Mass',
|
||||
pace: 'Pace',
|
||||
partsPer: 'Parts Per',
|
||||
power: 'Power',
|
||||
pressure: 'Pressure',
|
||||
reactiveEnergy: 'Reactive Energy',
|
||||
reactivePower: 'Reactive Power',
|
||||
speed: 'Speed',
|
||||
temperature: 'Temperature',
|
||||
tempo: 'Tempo / BPM',
|
||||
time: 'Time',
|
||||
voltage: 'Voltage',
|
||||
volume: 'Volume',
|
||||
volumeFlowRate: 'Volume Flow Rate',
|
||||
};
|
||||
|
||||
return nameMap[measure];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search units by query string (fuzzy search)
|
||||
*/
|
||||
export function searchUnits(query: string): UnitInfo[] {
|
||||
if (!query) return [];
|
||||
|
||||
const allMeasures = getAllMeasures();
|
||||
const results: UnitInfo[] = [];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
for (const measure of allMeasures) {
|
||||
const units = getUnitsForMeasure(measure);
|
||||
|
||||
for (const unit of units) {
|
||||
const info = getUnitInfo(unit);
|
||||
if (!info) continue;
|
||||
|
||||
const searchableText = [
|
||||
info.abbr,
|
||||
info.singular,
|
||||
info.plural,
|
||||
measure,
|
||||
formatMeasureName(measure),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
if (searchableText.includes(lowerQuery)) {
|
||||
results.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
106
lib/units/utils.ts
Normal file
106
lib/units/utils.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Utility functions for the application
|
||||
*/
|
||||
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Merge Tailwind CSS classes with clsx
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number for display with proper precision
|
||||
*/
|
||||
export function formatNumber(
|
||||
value: number,
|
||||
options: {
|
||||
maxDecimals?: number;
|
||||
minDecimals?: number;
|
||||
notation?: 'standard' | 'scientific' | 'engineering' | 'compact';
|
||||
} = {}
|
||||
): string {
|
||||
const {
|
||||
maxDecimals = 6,
|
||||
minDecimals = 0,
|
||||
notation = 'standard',
|
||||
} = options;
|
||||
|
||||
// Handle edge cases
|
||||
if (!isFinite(value)) return value.toString();
|
||||
if (value === 0) return '0';
|
||||
|
||||
// Use scientific notation for very large or very small numbers
|
||||
const absValue = Math.abs(value);
|
||||
const useScientific =
|
||||
notation === 'scientific' ||
|
||||
(notation === 'standard' && (absValue >= 1e10 || absValue < 1e-6));
|
||||
|
||||
if (useScientific) {
|
||||
return value.toExponential(maxDecimals);
|
||||
}
|
||||
|
||||
// Format with appropriate decimal places
|
||||
const formatted = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: minDecimals,
|
||||
maximumFractionDigits: maxDecimals,
|
||||
notation: notation === 'compact' ? 'compact' : 'standard',
|
||||
}).format(value);
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function for input handling
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
return function executedFunction(...args: Parameters<T>) {
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
func(...args);
|
||||
};
|
||||
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a number input string
|
||||
*/
|
||||
export function parseNumberInput(input: string): number | null {
|
||||
if (!input || input.trim() === '') return null;
|
||||
|
||||
// Remove spaces and replace comma with dot
|
||||
const cleaned = input.replace(/\s/g, '').replace(',', '.');
|
||||
|
||||
const parsed = parseFloat(cleaned);
|
||||
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative time from timestamp
|
||||
*/
|
||||
export function getRelativeTime(timestamp: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return 'just now';
|
||||
}
|
||||
Reference in New Issue
Block a user