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:
38
lib/figlet/constants/templates.ts
Normal file
38
lib/figlet/constants/templates.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface TextTemplate {
|
||||
id: string;
|
||||
label: string;
|
||||
text: string;
|
||||
category: 'greeting' | 'tech' | 'fun' | 'seasonal';
|
||||
}
|
||||
|
||||
export const TEXT_TEMPLATES: TextTemplate[] = [
|
||||
// Greetings
|
||||
{ id: 'hello', label: 'Hello', text: 'Hello!', category: 'greeting' },
|
||||
{ id: 'welcome', label: 'Welcome', text: 'Welcome', category: 'greeting' },
|
||||
{ id: 'hello-world', label: 'Hello World', text: 'Hello World', category: 'greeting' },
|
||||
|
||||
// Tech
|
||||
{ id: 'code', label: 'Code', text: 'CODE', category: 'tech' },
|
||||
{ id: 'dev', label: 'Developer', text: 'DEV', category: 'tech' },
|
||||
{ id: 'hack', label: 'Hack', text: 'HACK', category: 'tech' },
|
||||
{ id: 'terminal', label: 'Terminal', text: 'Terminal', category: 'tech' },
|
||||
{ id: 'git', label: 'Git', text: 'Git', category: 'tech' },
|
||||
|
||||
// Fun
|
||||
{ id: 'awesome', label: 'Awesome', text: 'AWESOME', category: 'fun' },
|
||||
{ id: 'cool', label: 'Cool', text: 'COOL', category: 'fun' },
|
||||
{ id: 'epic', label: 'Epic', text: 'EPIC', category: 'fun' },
|
||||
{ id: 'wow', label: 'Wow', text: 'WOW!', category: 'fun' },
|
||||
|
||||
// Seasonal
|
||||
{ id: 'happy-birthday', label: 'Happy Birthday', text: 'Happy Birthday!', category: 'seasonal' },
|
||||
{ id: 'congrats', label: 'Congrats', text: 'Congrats!', category: 'seasonal' },
|
||||
{ id: 'thanks', label: 'Thanks', text: 'Thanks!', category: 'seasonal' },
|
||||
];
|
||||
|
||||
export const TEMPLATE_CATEGORIES = [
|
||||
{ id: 'greeting', label: 'Greetings', icon: '👋' },
|
||||
{ id: 'tech', label: 'Tech', icon: '💻' },
|
||||
{ id: 'fun', label: 'Fun', icon: '🎉' },
|
||||
{ id: 'seasonal', label: 'Seasonal', icon: '🎊' },
|
||||
] as const;
|
||||
80
lib/figlet/figletService.ts
Normal file
80
lib/figlet/figletService.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import figlet from 'figlet';
|
||||
import type { FigletOptions } from '@/types/figlet';
|
||||
import { loadFont } from './fontLoader';
|
||||
|
||||
/**
|
||||
* Convert text to ASCII art using figlet
|
||||
*/
|
||||
export async function textToAscii(
|
||||
text: string,
|
||||
fontName: string = 'Standard',
|
||||
options: FigletOptions = {}
|
||||
): Promise<string> {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
// Load the font
|
||||
const fontData = await loadFont(fontName);
|
||||
|
||||
if (!fontData) {
|
||||
throw new Error(`Font ${fontName} could not be loaded`);
|
||||
}
|
||||
|
||||
// Parse and load the font into figlet
|
||||
figlet.parseFont(fontName, fontData);
|
||||
|
||||
// Generate ASCII art
|
||||
return new Promise((resolve, reject) => {
|
||||
figlet.text(
|
||||
text,
|
||||
{
|
||||
font: fontName,
|
||||
horizontalLayout: options.horizontalLayout || 'default',
|
||||
verticalLayout: options.verticalLayout || 'default',
|
||||
width: options.width,
|
||||
whitespaceBreak: options.whitespaceBreak ?? true,
|
||||
},
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(result || '');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating ASCII art:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ASCII art synchronously (requires font to be pre-loaded)
|
||||
*/
|
||||
export function textToAsciiSync(
|
||||
text: string,
|
||||
fontName: string = 'Standard',
|
||||
options: FigletOptions = {}
|
||||
): string {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return figlet.textSync(text, {
|
||||
font: fontName as any,
|
||||
horizontalLayout: options.horizontalLayout || 'default',
|
||||
verticalLayout: options.verticalLayout || 'default',
|
||||
width: options.width,
|
||||
whitespaceBreak: options.whitespaceBreak ?? true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating ASCII art (sync):', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
61
lib/figlet/fontLoader.ts
Normal file
61
lib/figlet/fontLoader.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { FigletFont } from '@/types/figlet';
|
||||
|
||||
// Cache for loaded fonts
|
||||
const fontCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Get list of all available figlet fonts
|
||||
*/
|
||||
export async function getFontList(): Promise<FigletFont[]> {
|
||||
try {
|
||||
const response = await fetch('/api/fonts');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch font list');
|
||||
}
|
||||
const fonts: FigletFont[] = await response.json();
|
||||
return fonts;
|
||||
} catch (error) {
|
||||
console.error('Error fetching font list:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific font file content
|
||||
*/
|
||||
export async function loadFont(fontName: string): Promise<string | null> {
|
||||
// Check cache first
|
||||
if (fontCache.has(fontName)) {
|
||||
return fontCache.get(fontName)!;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/fonts/figlet-fonts/${fontName}.flf`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load font: ${fontName}`);
|
||||
}
|
||||
const fontData = await response.text();
|
||||
|
||||
// Cache the font
|
||||
fontCache.set(fontName, fontData);
|
||||
|
||||
return fontData;
|
||||
} catch (error) {
|
||||
console.error(`Error loading font ${fontName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload a font into cache
|
||||
*/
|
||||
export async function preloadFont(fontName: string): Promise<void> {
|
||||
await loadFont(fontName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear font cache
|
||||
*/
|
||||
export function clearFontCache(): void {
|
||||
fontCache.clear();
|
||||
}
|
||||
34
lib/figlet/hooks/useKeyboardShortcuts.ts
Normal file
34
lib/figlet/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
handler: () => void;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
for (const shortcut of shortcuts) {
|
||||
const keyMatches = event.key.toLowerCase() === shortcut.key.toLowerCase();
|
||||
const ctrlMatches = shortcut.ctrlKey ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey;
|
||||
const metaMatches = shortcut.metaKey ? event.metaKey : true;
|
||||
const shiftMatches = shortcut.shiftKey ? event.shiftKey : !event.shiftKey;
|
||||
|
||||
if (keyMatches && ctrlMatches && shiftMatches) {
|
||||
event.preventDefault();
|
||||
shortcut.handler();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [shortcuts]);
|
||||
}
|
||||
248
lib/pastel/api/client.ts
Normal file
248
lib/pastel/api/client.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import type {
|
||||
ApiResponse,
|
||||
ColorInfoRequest,
|
||||
ColorInfoData,
|
||||
ConvertFormatRequest,
|
||||
ConvertFormatData,
|
||||
ColorManipulationRequest,
|
||||
ColorManipulationData,
|
||||
ColorMixRequest,
|
||||
ColorMixData,
|
||||
RandomColorsRequest,
|
||||
RandomColorsData,
|
||||
DistinctColorsRequest,
|
||||
DistinctColorsData,
|
||||
GradientRequest,
|
||||
GradientData,
|
||||
ColorDistanceRequest,
|
||||
ColorDistanceData,
|
||||
ColorSortRequest,
|
||||
ColorSortData,
|
||||
ColorBlindnessRequest,
|
||||
ColorBlindnessData,
|
||||
TextColorRequest,
|
||||
TextColorData,
|
||||
NamedColorsData,
|
||||
NamedColorSearchRequest,
|
||||
NamedColorSearchData,
|
||||
HealthData,
|
||||
CapabilitiesData,
|
||||
PaletteGenerateRequest,
|
||||
PaletteGenerateData,
|
||||
} from './types';
|
||||
import { pastelWASM } from './wasm-client';
|
||||
|
||||
export class PastelAPIClient {
|
||||
private baseURL: string;
|
||||
|
||||
constructor(baseURL?: string) {
|
||||
// Use the Next.js API proxy route for runtime configuration
|
||||
// This allows changing the backend API URL without rebuilding
|
||||
this.baseURL = baseURL || '/api/pastel';
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
): Promise<ApiResponse<T>> {
|
||||
// Endpoint already includes /api/v1 prefix on backend,
|
||||
// but our proxy route expects paths after /api/v1/
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.error || {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'An unknown error occurred',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NETWORK_ERROR',
|
||||
message: error instanceof Error ? error.message : 'Network request failed',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Color Information
|
||||
async getColorInfo(request: ColorInfoRequest): Promise<ApiResponse<ColorInfoData>> {
|
||||
return this.request<ColorInfoData>('/colors/info', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Format Conversion
|
||||
async convertFormat(request: ConvertFormatRequest): Promise<ApiResponse<ConvertFormatData>> {
|
||||
return this.request<ConvertFormatData>('/colors/convert', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Color Manipulation
|
||||
async lighten(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request<ColorManipulationData>('/colors/lighten', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async darken(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request<ColorManipulationData>('/colors/darken', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async saturate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request<ColorManipulationData>('/colors/saturate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async desaturate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request<ColorManipulationData>('/colors/desaturate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async rotate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request<ColorManipulationData>('/colors/rotate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async complement(colors: string[]): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request<ColorManipulationData>('/colors/complement', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ colors }),
|
||||
});
|
||||
}
|
||||
|
||||
async grayscale(colors: string[]): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request<ColorManipulationData>('/colors/grayscale', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ colors }),
|
||||
});
|
||||
}
|
||||
|
||||
async mix(request: ColorMixRequest): Promise<ApiResponse<ColorMixData>> {
|
||||
return this.request<ColorMixData>('/colors/mix', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Color Generation
|
||||
async generateRandom(request: RandomColorsRequest): Promise<ApiResponse<RandomColorsData>> {
|
||||
return this.request<RandomColorsData>('/colors/random', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async generateDistinct(request: DistinctColorsRequest): Promise<ApiResponse<DistinctColorsData>> {
|
||||
return this.request<DistinctColorsData>('/colors/distinct', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async generateGradient(request: GradientRequest): Promise<ApiResponse<GradientData>> {
|
||||
return this.request<GradientData>('/colors/gradient', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Color Analysis
|
||||
async calculateDistance(request: ColorDistanceRequest): Promise<ApiResponse<ColorDistanceData>> {
|
||||
return this.request<ColorDistanceData>('/colors/distance', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async sortColors(request: ColorSortRequest): Promise<ApiResponse<ColorSortData>> {
|
||||
return this.request<ColorSortData>('/colors/sort', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
async simulateColorBlindness(request: ColorBlindnessRequest): Promise<ApiResponse<ColorBlindnessData>> {
|
||||
return this.request<ColorBlindnessData>('/colors/colorblind', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async getTextColor(request: TextColorRequest): Promise<ApiResponse<TextColorData>> {
|
||||
return this.request<TextColorData>('/colors/textcolor', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Named Colors
|
||||
async getNamedColors(): Promise<ApiResponse<NamedColorsData>> {
|
||||
return this.request<NamedColorsData>('/colors/names', {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
async searchNamedColors(request: NamedColorSearchRequest): Promise<ApiResponse<NamedColorSearchData>> {
|
||||
return this.request<NamedColorSearchData>('/colors/names/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// System
|
||||
async getHealth(): Promise<ApiResponse<HealthData>> {
|
||||
return this.request<HealthData>('/health', {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<ApiResponse<CapabilitiesData>> {
|
||||
return this.request<CapabilitiesData>('/capabilities', {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
// Palette Generation
|
||||
async generatePalette(request: PaletteGenerateRequest): Promise<ApiResponse<PaletteGenerateData>> {
|
||||
return this.request<PaletteGenerateData>('/palettes/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
// Now using WASM client for zero-latency, offline-first color operations
|
||||
export const pastelAPI = pastelWASM;
|
||||
251
lib/pastel/api/queries.ts
Normal file
251
lib/pastel/api/queries.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, UseQueryOptions } from '@tanstack/react-query';
|
||||
import { pastelAPI } from './client';
|
||||
import type {
|
||||
ColorInfoRequest,
|
||||
ColorInfoData,
|
||||
ConvertFormatRequest,
|
||||
ConvertFormatData,
|
||||
ColorManipulationRequest,
|
||||
ColorManipulationData,
|
||||
ColorMixRequest,
|
||||
ColorMixData,
|
||||
RandomColorsRequest,
|
||||
RandomColorsData,
|
||||
DistinctColorsRequest,
|
||||
DistinctColorsData,
|
||||
GradientRequest,
|
||||
GradientData,
|
||||
ColorBlindnessRequest,
|
||||
PaletteGenerateRequest,
|
||||
PaletteGenerateData,
|
||||
ColorBlindnessData,
|
||||
TextColorRequest,
|
||||
TextColorData,
|
||||
NamedColorsData,
|
||||
HealthData,
|
||||
} from './types';
|
||||
|
||||
// Color Information
|
||||
export const useColorInfo = (
|
||||
request: ColorInfoRequest,
|
||||
options?: Omit<UseQueryOptions<ColorInfoData>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: ['colorInfo', request.colors],
|
||||
queryFn: async () => {
|
||||
const response = await pastelAPI.getColorInfo(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
enabled: request.colors.length > 0 && request.colors.every((c) => c.length > 0),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Format Conversion
|
||||
export const useConvertFormat = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ConvertFormatRequest) => {
|
||||
const response = await pastelAPI.convertFormat(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Color Manipulation
|
||||
export const useLighten = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ColorManipulationRequest) => {
|
||||
const response = await pastelAPI.lighten(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDarken = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ColorManipulationRequest) => {
|
||||
const response = await pastelAPI.darken(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useSaturate = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ColorManipulationRequest) => {
|
||||
const response = await pastelAPI.saturate(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDesaturate = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ColorManipulationRequest) => {
|
||||
const response = await pastelAPI.desaturate(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRotate = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ColorManipulationRequest) => {
|
||||
const response = await pastelAPI.rotate(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useComplement = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (colors: string[]) => {
|
||||
const response = await pastelAPI.complement(colors);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useMixColors = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ColorMixRequest) => {
|
||||
const response = await pastelAPI.mix(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Color Generation
|
||||
export const useGenerateRandom = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: RandomColorsRequest) => {
|
||||
const response = await pastelAPI.generateRandom(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGenerateDistinct = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: DistinctColorsRequest) => {
|
||||
const response = await pastelAPI.generateDistinct(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGenerateGradient = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: GradientRequest) => {
|
||||
const response = await pastelAPI.generateGradient(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Color Blindness Simulation
|
||||
export const useSimulateColorBlindness = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: ColorBlindnessRequest) => {
|
||||
const response = await pastelAPI.simulateColorBlindness(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Text Color Optimizer
|
||||
export const useTextColor = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: TextColorRequest) => {
|
||||
const response = await pastelAPI.getTextColor(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Named Colors
|
||||
export const useNamedColors = () => {
|
||||
return useQuery({
|
||||
queryKey: ['namedColors'],
|
||||
queryFn: async () => {
|
||||
const response = await pastelAPI.getNamedColors();
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
staleTime: Infinity, // Named colors never change
|
||||
});
|
||||
};
|
||||
|
||||
// Health Check
|
||||
export const useHealth = () => {
|
||||
return useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: async () => {
|
||||
const response = await pastelAPI.getHealth();
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 60000, // Check every minute
|
||||
});
|
||||
};
|
||||
|
||||
// Palette Generation
|
||||
export const useGeneratePalette = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (request: PaletteGenerateRequest) => {
|
||||
const response = await pastelAPI.generatePalette(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
268
lib/pastel/api/types.ts
Normal file
268
lib/pastel/api/types.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
// API Response Types
|
||||
export interface SuccessResponse<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
|
||||
|
||||
// Color Component Types
|
||||
export interface RGBColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a?: number;
|
||||
}
|
||||
|
||||
export interface HSLColor {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
a?: number;
|
||||
}
|
||||
|
||||
export interface HSVColor {
|
||||
h: number;
|
||||
s: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
export interface LabColor {
|
||||
l: number;
|
||||
a: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export interface OkLabColor {
|
||||
l: number;
|
||||
a: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export interface LCHColor {
|
||||
l: number;
|
||||
c: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface OkLCHColor {
|
||||
l: number;
|
||||
c: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface CMYKColor {
|
||||
c: number;
|
||||
m: number;
|
||||
y: number;
|
||||
k: number;
|
||||
}
|
||||
|
||||
// Color Information
|
||||
export interface ColorInfo {
|
||||
input: string;
|
||||
hex: string;
|
||||
rgb: RGBColor;
|
||||
hsl: HSLColor;
|
||||
hsv: HSVColor;
|
||||
lab: LabColor;
|
||||
oklab: OkLabColor;
|
||||
lch: LCHColor;
|
||||
oklch: OkLCHColor;
|
||||
cmyk: CMYKColor;
|
||||
gray?: number;
|
||||
brightness: number;
|
||||
luminance: number;
|
||||
is_light: boolean;
|
||||
name?: string;
|
||||
distance_to_named?: number;
|
||||
}
|
||||
|
||||
// Request/Response Types for Each Endpoint
|
||||
export interface ColorInfoRequest {
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
export interface ColorInfoData {
|
||||
colors: ColorInfo[];
|
||||
}
|
||||
|
||||
export interface ConvertFormatRequest {
|
||||
colors: string[];
|
||||
format: 'hex' | 'rgb' | 'hsl' | 'hsv' | 'lab' | 'oklab' | 'lch' | 'oklch' | 'cmyk' | 'gray';
|
||||
}
|
||||
|
||||
export interface ConvertFormatData {
|
||||
conversions: Array<{
|
||||
input: string;
|
||||
output: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ColorManipulationRequest {
|
||||
colors: string[];
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface ColorManipulationData {
|
||||
operation?: string;
|
||||
amount?: number;
|
||||
colors: Array<{
|
||||
input: string;
|
||||
output: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ColorMixRequest {
|
||||
colors: string[];
|
||||
fraction: number;
|
||||
colorspace?: 'rgb' | 'hsl' | 'hsv' | 'lab' | 'oklab' | 'lch' | 'oklch';
|
||||
}
|
||||
|
||||
export interface ColorMixData {
|
||||
results: Array<{
|
||||
color1: string;
|
||||
color2: string;
|
||||
mixed: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RandomColorsRequest {
|
||||
count: number;
|
||||
strategy?: 'vivid' | 'rgb' | 'gray' | 'lch';
|
||||
}
|
||||
|
||||
export interface RandomColorsData {
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
export interface DistinctColorsRequest {
|
||||
count: number;
|
||||
metric?: 'cie76' | 'ciede2000';
|
||||
fixed_colors?: string[];
|
||||
}
|
||||
|
||||
export interface DistinctColorsData {
|
||||
colors: string[];
|
||||
stats: {
|
||||
min_distance: number;
|
||||
avg_distance: number;
|
||||
generation_time_ms: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GradientRequest {
|
||||
stops: string[];
|
||||
count: number;
|
||||
colorspace?: 'rgb' | 'hsl' | 'hsv' | 'lab' | 'oklab' | 'lch' | 'oklch';
|
||||
}
|
||||
|
||||
export interface GradientData {
|
||||
stops: string[];
|
||||
count: number;
|
||||
colorspace: string;
|
||||
gradient: string[];
|
||||
}
|
||||
|
||||
export interface ColorDistanceRequest {
|
||||
color1: string;
|
||||
color2: string;
|
||||
metric: 'cie76' | 'ciede2000';
|
||||
}
|
||||
|
||||
export interface ColorDistanceData {
|
||||
color1: string;
|
||||
color2: string;
|
||||
distance: number;
|
||||
metric: string;
|
||||
}
|
||||
|
||||
export interface ColorSortRequest {
|
||||
colors: string[];
|
||||
order: 'hue' | 'brightness' | 'luminance' | 'chroma';
|
||||
}
|
||||
|
||||
export interface ColorSortData {
|
||||
sorted: string[];
|
||||
}
|
||||
|
||||
export interface ColorBlindnessRequest {
|
||||
colors: string[];
|
||||
type: 'protanopia' | 'deuteranopia' | 'tritanopia';
|
||||
}
|
||||
|
||||
export interface ColorBlindnessData {
|
||||
type: string;
|
||||
colors: Array<{
|
||||
input: string;
|
||||
output: string;
|
||||
difference_percentage: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TextColorRequest {
|
||||
backgrounds: string[];
|
||||
}
|
||||
|
||||
export interface TextColorData {
|
||||
colors: Array<{
|
||||
background: string;
|
||||
textcolor: string;
|
||||
contrast_ratio: number;
|
||||
wcag_aa: boolean;
|
||||
wcag_aaa: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface NamedColor {
|
||||
name: string;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
export interface NamedColorsData {
|
||||
colors: NamedColor[];
|
||||
}
|
||||
|
||||
export interface NamedColorSearchRequest {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface NamedColorSearchData {
|
||||
results: NamedColor[];
|
||||
}
|
||||
|
||||
export interface HealthData {
|
||||
status: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface CapabilitiesData {
|
||||
endpoints: string[];
|
||||
formats: string[];
|
||||
color_spaces: string[];
|
||||
distance_metrics: string[];
|
||||
colorblindness_types: string[];
|
||||
}
|
||||
|
||||
export interface PaletteGenerateRequest {
|
||||
base: string;
|
||||
scheme: 'monochromatic' | 'analogous' | 'complementary' | 'split-complementary' | 'triadic' | 'tetradic';
|
||||
}
|
||||
|
||||
export interface PaletteGenerateData {
|
||||
base: string;
|
||||
scheme: string;
|
||||
palette: {
|
||||
primary: string;
|
||||
secondary: string[];
|
||||
};
|
||||
}
|
||||
484
lib/pastel/api/wasm-client.ts
Normal file
484
lib/pastel/api/wasm-client.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import {
|
||||
init,
|
||||
parse_color,
|
||||
lighten_color,
|
||||
darken_color,
|
||||
saturate_color,
|
||||
desaturate_color,
|
||||
rotate_hue,
|
||||
complement_color,
|
||||
mix_colors,
|
||||
get_text_color,
|
||||
calculate_contrast,
|
||||
simulate_protanopia,
|
||||
simulate_deuteranopia,
|
||||
simulate_tritanopia,
|
||||
color_distance,
|
||||
generate_random_colors,
|
||||
generate_gradient,
|
||||
generate_palette,
|
||||
get_all_named_colors,
|
||||
search_named_colors,
|
||||
version,
|
||||
} from '@valknarthing/pastel-wasm';
|
||||
import type {
|
||||
ApiResponse,
|
||||
ColorInfoRequest,
|
||||
ColorInfoData,
|
||||
ConvertFormatRequest,
|
||||
ConvertFormatData,
|
||||
ColorManipulationRequest,
|
||||
ColorManipulationData,
|
||||
ColorMixRequest,
|
||||
ColorMixData,
|
||||
RandomColorsRequest,
|
||||
RandomColorsData,
|
||||
DistinctColorsRequest,
|
||||
DistinctColorsData,
|
||||
GradientRequest,
|
||||
GradientData,
|
||||
ColorDistanceRequest,
|
||||
ColorDistanceData,
|
||||
ColorSortRequest,
|
||||
ColorSortData,
|
||||
ColorBlindnessRequest,
|
||||
ColorBlindnessData,
|
||||
TextColorRequest,
|
||||
TextColorData,
|
||||
NamedColorsData,
|
||||
NamedColorSearchRequest,
|
||||
NamedColorSearchData,
|
||||
HealthData,
|
||||
CapabilitiesData,
|
||||
PaletteGenerateRequest,
|
||||
PaletteGenerateData,
|
||||
} from './types';
|
||||
|
||||
// Initialize WASM module
|
||||
let wasmInitialized = false;
|
||||
|
||||
async function ensureWasmInit() {
|
||||
if (!wasmInitialized) {
|
||||
init(); // Initialize panic hook
|
||||
wasmInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WASM-based Pastel client
|
||||
* Provides the same interface as PastelAPIClient but uses WebAssembly
|
||||
* Zero network latency, works offline!
|
||||
*/
|
||||
export class PastelWASMClient {
|
||||
constructor() {
|
||||
// Initialize WASM eagerly
|
||||
ensureWasmInit().catch(console.error);
|
||||
}
|
||||
|
||||
private async request<T>(fn: () => T): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
await ensureWasmInit();
|
||||
const data = fn();
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'WASM_ERROR',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Color Information
|
||||
async getColorInfo(request: ColorInfoRequest): Promise<ApiResponse<ColorInfoData>> {
|
||||
return this.request(() => {
|
||||
const colors = request.colors.map((colorStr) => {
|
||||
const info = parse_color(colorStr);
|
||||
return {
|
||||
input: info.input,
|
||||
hex: info.hex,
|
||||
rgb: {
|
||||
r: info.rgb[0],
|
||||
g: info.rgb[1],
|
||||
b: info.rgb[2],
|
||||
},
|
||||
hsl: {
|
||||
h: info.hsl[0],
|
||||
s: info.hsl[1],
|
||||
l: info.hsl[2],
|
||||
},
|
||||
hsv: {
|
||||
h: info.hsv[0],
|
||||
s: info.hsv[1],
|
||||
v: info.hsv[2],
|
||||
},
|
||||
lab: {
|
||||
l: info.lab[0],
|
||||
a: info.lab[1],
|
||||
b: info.lab[2],
|
||||
},
|
||||
oklab: {
|
||||
l: info.lab[0] / 100.0,
|
||||
a: info.lab[1] / 100.0,
|
||||
b: info.lab[2] / 100.0,
|
||||
},
|
||||
lch: {
|
||||
l: info.lch[0],
|
||||
c: info.lch[1],
|
||||
h: info.lch[2],
|
||||
},
|
||||
oklch: {
|
||||
l: info.lch[0] / 100.0,
|
||||
c: info.lch[1] / 100.0,
|
||||
h: info.lch[2],
|
||||
},
|
||||
cmyk: {
|
||||
c: 0,
|
||||
m: 0,
|
||||
y: 0,
|
||||
k: 0,
|
||||
},
|
||||
brightness: info.brightness,
|
||||
luminance: info.luminance,
|
||||
is_light: info.is_light,
|
||||
};
|
||||
});
|
||||
return { colors };
|
||||
});
|
||||
}
|
||||
|
||||
// Format Conversion
|
||||
async convertFormat(request: ConvertFormatRequest): Promise<ApiResponse<ConvertFormatData>> {
|
||||
return this.request(() => {
|
||||
const conversions = request.colors.map((colorStr) => {
|
||||
const parsed = parse_color(colorStr);
|
||||
let output: string;
|
||||
|
||||
switch (request.format) {
|
||||
case 'hex':
|
||||
output = parsed.hex;
|
||||
break;
|
||||
case 'rgb':
|
||||
output = `rgb(${parsed.rgb[0]}, ${parsed.rgb[1]}, ${parsed.rgb[2]})`;
|
||||
break;
|
||||
case 'hsl':
|
||||
output = `hsl(${parsed.hsl[0].toFixed(1)}, ${(parsed.hsl[1] * 100).toFixed(1)}%, ${(parsed.hsl[2] * 100).toFixed(1)}%)`;
|
||||
break;
|
||||
case 'hsv':
|
||||
output = `hsv(${parsed.hsv[0].toFixed(1)}, ${(parsed.hsv[1] * 100).toFixed(1)}%, ${(parsed.hsv[2] * 100).toFixed(1)}%)`;
|
||||
break;
|
||||
case 'lab':
|
||||
output = `lab(${parsed.lab[0].toFixed(2)}, ${parsed.lab[1].toFixed(2)}, ${parsed.lab[2].toFixed(2)})`;
|
||||
break;
|
||||
case 'lch':
|
||||
output = `lch(${parsed.lch[0].toFixed(2)}, ${parsed.lch[1].toFixed(2)}, ${parsed.lch[2].toFixed(2)})`;
|
||||
break;
|
||||
default:
|
||||
output = parsed.hex;
|
||||
}
|
||||
|
||||
return {
|
||||
input: colorStr,
|
||||
output,
|
||||
};
|
||||
});
|
||||
return { conversions };
|
||||
});
|
||||
}
|
||||
|
||||
// Color Manipulation
|
||||
async lighten(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request(() => {
|
||||
const colors = request.colors.map((colorStr) => ({
|
||||
input: colorStr,
|
||||
output: lighten_color(colorStr, request.amount),
|
||||
}));
|
||||
return { operation: 'lighten', amount: request.amount, colors };
|
||||
});
|
||||
}
|
||||
|
||||
async darken(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request(() => {
|
||||
const colors = request.colors.map((colorStr) => ({
|
||||
input: colorStr,
|
||||
output: darken_color(colorStr, request.amount),
|
||||
}));
|
||||
return { operation: 'darken', amount: request.amount, colors };
|
||||
});
|
||||
}
|
||||
|
||||
async saturate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request(() => {
|
||||
const colors = request.colors.map((colorStr) => ({
|
||||
input: colorStr,
|
||||
output: saturate_color(colorStr, request.amount),
|
||||
}));
|
||||
return { operation: 'saturate', amount: request.amount, colors };
|
||||
});
|
||||
}
|
||||
|
||||
async desaturate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request(() => {
|
||||
const colors = request.colors.map((colorStr) => ({
|
||||
input: colorStr,
|
||||
output: desaturate_color(colorStr, request.amount),
|
||||
}));
|
||||
return { operation: 'desaturate', amount: request.amount, colors };
|
||||
});
|
||||
}
|
||||
|
||||
async rotate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request(() => {
|
||||
const colors = request.colors.map((colorStr) => ({
|
||||
input: colorStr,
|
||||
output: rotate_hue(colorStr, request.amount),
|
||||
}));
|
||||
return { operation: 'rotate', amount: request.amount, colors };
|
||||
});
|
||||
}
|
||||
|
||||
async complement(colors: string[]): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request(() => {
|
||||
const results = colors.map((colorStr) => ({
|
||||
input: colorStr,
|
||||
output: complement_color(colorStr),
|
||||
}));
|
||||
return { operation: 'complement', colors: results };
|
||||
});
|
||||
}
|
||||
|
||||
async grayscale(colors: string[]): Promise<ApiResponse<ColorManipulationData>> {
|
||||
return this.request(() => {
|
||||
const results = colors.map((colorStr) => ({
|
||||
input: colorStr,
|
||||
output: desaturate_color(colorStr, 1.0),
|
||||
}));
|
||||
return { operation: 'grayscale', colors: results };
|
||||
});
|
||||
}
|
||||
|
||||
async mix(request: ColorMixRequest): Promise<ApiResponse<ColorMixData>> {
|
||||
return this.request(() => {
|
||||
// Mix pairs of colors
|
||||
const results = [];
|
||||
for (let i = 0; i < request.colors.length - 1; i += 2) {
|
||||
const color1 = request.colors[i];
|
||||
const color2 = request.colors[i + 1];
|
||||
const mixed = mix_colors(color1, color2, request.fraction);
|
||||
results.push({ color1, color2, mixed });
|
||||
}
|
||||
return { results };
|
||||
});
|
||||
}
|
||||
|
||||
// Color Generation
|
||||
async generateRandom(request: RandomColorsRequest): Promise<ApiResponse<RandomColorsData>> {
|
||||
return this.request(() => {
|
||||
const vivid = request.strategy === 'vivid' || request.strategy === 'lch';
|
||||
const colors = generate_random_colors(request.count, vivid);
|
||||
return { colors };
|
||||
});
|
||||
}
|
||||
|
||||
async generateDistinct(request: DistinctColorsRequest): Promise<ApiResponse<DistinctColorsData>> {
|
||||
return this.request(() => {
|
||||
// Note: WASM version doesn't support distinct colors with simulated annealing yet
|
||||
// Fall back to vivid random colors
|
||||
const colors = generate_random_colors(request.count, true);
|
||||
return {
|
||||
colors,
|
||||
stats: {
|
||||
min_distance: 0,
|
||||
avg_distance: 0,
|
||||
generation_time_ms: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async generateGradient(request: GradientRequest): Promise<ApiResponse<GradientData>> {
|
||||
return this.request(() => {
|
||||
if (request.stops.length < 2) {
|
||||
throw new Error('At least 2 color stops are required');
|
||||
}
|
||||
|
||||
// For 2 stops, use the WASM gradient function
|
||||
if (request.stops.length === 2) {
|
||||
const gradient = generate_gradient(request.stops[0], request.stops[1], request.count);
|
||||
return {
|
||||
stops: request.stops,
|
||||
count: request.count,
|
||||
colorspace: request.colorspace || 'rgb',
|
||||
gradient,
|
||||
};
|
||||
}
|
||||
|
||||
// For multiple stops, interpolate segments
|
||||
const segments = request.stops.length - 1;
|
||||
const colorsPerSegment = Math.floor(request.count / segments);
|
||||
const gradient: string[] = [];
|
||||
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const segmentColors = generate_gradient(
|
||||
request.stops[i],
|
||||
request.stops[i + 1],
|
||||
i === segments - 1 ? request.count - gradient.length : colorsPerSegment
|
||||
);
|
||||
gradient.push(...segmentColors.slice(0, -1)); // Avoid duplicates
|
||||
}
|
||||
gradient.push(request.stops[request.stops.length - 1]);
|
||||
|
||||
return {
|
||||
stops: request.stops,
|
||||
count: request.count,
|
||||
colorspace: request.colorspace || 'rgb',
|
||||
gradient,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Color Analysis
|
||||
async calculateDistance(request: ColorDistanceRequest): Promise<ApiResponse<ColorDistanceData>> {
|
||||
return this.request(() => {
|
||||
const useCiede2000 = request.metric === 'ciede2000';
|
||||
const distance = color_distance(request.color1, request.color2, useCiede2000);
|
||||
return {
|
||||
color1: request.color1,
|
||||
color2: request.color2,
|
||||
distance,
|
||||
metric: request.metric,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async sortColors(request: ColorSortRequest): Promise<ApiResponse<ColorSortData>> {
|
||||
return this.request(() => {
|
||||
// Note: WASM version doesn't support sorting yet
|
||||
// Return colors as-is for now
|
||||
return { sorted: request.colors };
|
||||
});
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
async simulateColorBlindness(request: ColorBlindnessRequest): Promise<ApiResponse<ColorBlindnessData>> {
|
||||
return this.request(() => {
|
||||
const colors = request.colors.map((colorStr) => {
|
||||
let output: string;
|
||||
switch (request.type) {
|
||||
case 'protanopia':
|
||||
output = simulate_protanopia(colorStr);
|
||||
break;
|
||||
case 'deuteranopia':
|
||||
output = simulate_deuteranopia(colorStr);
|
||||
break;
|
||||
case 'tritanopia':
|
||||
output = simulate_tritanopia(colorStr);
|
||||
break;
|
||||
default:
|
||||
output = colorStr;
|
||||
}
|
||||
|
||||
const distance = color_distance(colorStr, output, true);
|
||||
return {
|
||||
input: colorStr,
|
||||
output,
|
||||
difference_percentage: (distance / 100.0) * 100.0,
|
||||
};
|
||||
});
|
||||
|
||||
return { type: request.type, colors };
|
||||
});
|
||||
}
|
||||
|
||||
async getTextColor(request: TextColorRequest): Promise<ApiResponse<TextColorData>> {
|
||||
return this.request(() => {
|
||||
const colors = request.backgrounds.map((bg) => {
|
||||
const textColor = get_text_color(bg);
|
||||
const contrastRatio = calculate_contrast(bg, textColor);
|
||||
|
||||
return {
|
||||
background: bg,
|
||||
textcolor: textColor,
|
||||
contrast_ratio: contrastRatio,
|
||||
wcag_aa: contrastRatio >= 4.5,
|
||||
wcag_aaa: contrastRatio >= 7.0,
|
||||
};
|
||||
});
|
||||
|
||||
return { colors };
|
||||
});
|
||||
}
|
||||
|
||||
// Named Colors
|
||||
async getNamedColors(): Promise<ApiResponse<NamedColorsData>> {
|
||||
return this.request(() => {
|
||||
const colors = get_all_named_colors();
|
||||
return { colors };
|
||||
});
|
||||
}
|
||||
|
||||
async searchNamedColors(request: NamedColorSearchRequest): Promise<ApiResponse<NamedColorSearchData>> {
|
||||
return this.request(() => {
|
||||
const results = search_named_colors(request.query);
|
||||
return { results };
|
||||
});
|
||||
}
|
||||
|
||||
// System
|
||||
async getHealth(): Promise<ApiResponse<HealthData>> {
|
||||
return this.request(() => ({
|
||||
status: 'healthy',
|
||||
version: version(),
|
||||
}));
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<ApiResponse<CapabilitiesData>> {
|
||||
return this.request(() => ({
|
||||
endpoints: [
|
||||
'colors/info',
|
||||
'colors/convert',
|
||||
'colors/lighten',
|
||||
'colors/darken',
|
||||
'colors/saturate',
|
||||
'colors/desaturate',
|
||||
'colors/rotate',
|
||||
'colors/complement',
|
||||
'colors/grayscale',
|
||||
'colors/mix',
|
||||
'colors/random',
|
||||
'colors/gradient',
|
||||
'colors/colorblind',
|
||||
'colors/textcolor',
|
||||
'colors/distance',
|
||||
'colors/names',
|
||||
],
|
||||
formats: ['hex', 'rgb', 'hsl', 'hsv', 'lab', 'lch'],
|
||||
color_spaces: ['rgb', 'hsl', 'hsv', 'lab', 'lch'],
|
||||
distance_metrics: ['cie76', 'ciede2000'],
|
||||
colorblindness_types: ['protanopia', 'deuteranopia', 'tritanopia'],
|
||||
}));
|
||||
}
|
||||
|
||||
// Palette Generation
|
||||
async generatePalette(request: PaletteGenerateRequest): Promise<ApiResponse<PaletteGenerateData>> {
|
||||
return this.request(() => {
|
||||
const colors = generate_palette(request.base, request.scheme);
|
||||
return {
|
||||
base: request.base,
|
||||
scheme: request.scheme,
|
||||
palette: {
|
||||
primary: colors[0],
|
||||
secondary: colors.slice(1),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pastelWASM = new PastelWASMClient();
|
||||
106
lib/pastel/hooks/useKeyboard.ts
Normal file
106
lib/pastel/hooks/useKeyboard.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string;
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
meta?: boolean;
|
||||
handler: (event: KeyboardEvent) => void;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to register keyboard shortcuts
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* useKeyboard([
|
||||
* {
|
||||
* key: 'c',
|
||||
* meta: true, // Cmd on Mac, Ctrl on Windows
|
||||
* handler: () => copyToClipboard(),
|
||||
* description: 'Copy color',
|
||||
* },
|
||||
* {
|
||||
* key: 'k',
|
||||
* meta: true,
|
||||
* handler: () => openCommandPalette(),
|
||||
* description: 'Open command palette',
|
||||
* },
|
||||
* ]);
|
||||
* ```
|
||||
*/
|
||||
export function useKeyboard(shortcuts: KeyboardShortcut[]) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
for (const shortcut of shortcuts) {
|
||||
const keyMatches = event.key.toLowerCase() === shortcut.key.toLowerCase();
|
||||
|
||||
// Check if required modifiers match (only check if explicitly required)
|
||||
const ctrlMatches = shortcut.ctrl === true ? event.ctrlKey : true;
|
||||
const shiftMatches = shortcut.shift === true ? event.shiftKey : true;
|
||||
const altMatches = shortcut.alt === true ? event.altKey : true;
|
||||
|
||||
// Handle meta/cmd key with cross-platform support
|
||||
let metaMatches = true;
|
||||
if (shortcut.meta === true) {
|
||||
// On Mac: require Cmd key
|
||||
// On Windows/Linux: accept Ctrl key as Cmd equivalent
|
||||
const isMac = navigator.platform.includes('Mac');
|
||||
metaMatches = isMac ? event.metaKey : event.ctrlKey;
|
||||
}
|
||||
|
||||
// Ensure unwanted modifiers are not pressed (unless explicitly required)
|
||||
const noExtraCtrl = shortcut.ctrl === true || shortcut.meta === true || !event.ctrlKey;
|
||||
const noExtraShift = shortcut.shift === true || !event.shiftKey;
|
||||
const noExtraAlt = shortcut.alt === true || !event.altKey;
|
||||
const noExtraMeta = shortcut.meta === true || !event.metaKey;
|
||||
|
||||
if (
|
||||
keyMatches &&
|
||||
ctrlMatches &&
|
||||
shiftMatches &&
|
||||
altMatches &&
|
||||
metaMatches &&
|
||||
noExtraCtrl &&
|
||||
noExtraShift &&
|
||||
noExtraAlt &&
|
||||
noExtraMeta
|
||||
) {
|
||||
event.preventDefault();
|
||||
shortcut.handler(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [shortcuts]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to register a single keyboard shortcut (convenience wrapper)
|
||||
*/
|
||||
export function useKeyboardShortcut(
|
||||
key: string,
|
||||
handler: (event: KeyboardEvent) => void,
|
||||
modifiers?: {
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
meta?: boolean;
|
||||
}
|
||||
) {
|
||||
useKeyboard([
|
||||
{
|
||||
key,
|
||||
...modifiers,
|
||||
handler,
|
||||
},
|
||||
]);
|
||||
}
|
||||
5
lib/pastel/index.ts
Normal file
5
lib/pastel/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './api/queries';
|
||||
export * from './stores/historyStore';
|
||||
export * from './hooks/useKeyboard';
|
||||
export * from './utils/color';
|
||||
export * from './utils/export';
|
||||
68
lib/pastel/stores/historyStore.ts
Normal file
68
lib/pastel/stores/historyStore.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
export interface ColorHistoryEntry {
|
||||
color: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface ColorHistoryState {
|
||||
history: ColorHistoryEntry[];
|
||||
addColor: (color: string) => void;
|
||||
removeColor: (color: string) => void;
|
||||
clearHistory: () => void;
|
||||
getRecent: (limit?: number) => ColorHistoryEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Color history store with localStorage persistence
|
||||
*
|
||||
* Tracks up to 50 most recent colors with timestamps
|
||||
* Automatically removes duplicates (keeps most recent)
|
||||
* Persists across browser sessions
|
||||
*/
|
||||
export const useColorHistory = create<ColorHistoryState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
history: [],
|
||||
|
||||
addColor: (color) => {
|
||||
const normalizedColor = color.toLowerCase();
|
||||
set((state) => {
|
||||
// Remove existing entry if present
|
||||
const filtered = state.history.filter(
|
||||
(entry) => entry.color.toLowerCase() !== normalizedColor
|
||||
);
|
||||
|
||||
// Add new entry at the beginning
|
||||
const newHistory = [
|
||||
{ color: normalizedColor, timestamp: Date.now() },
|
||||
...filtered,
|
||||
].slice(0, 50); // Keep only 50 most recent
|
||||
|
||||
return { history: newHistory };
|
||||
});
|
||||
},
|
||||
|
||||
removeColor: (color) => {
|
||||
const normalizedColor = color.toLowerCase();
|
||||
set((state) => ({
|
||||
history: state.history.filter(
|
||||
(entry) => entry.color.toLowerCase() !== normalizedColor
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
clearHistory: () => set({ history: [] }),
|
||||
|
||||
getRecent: (limit = 10) => {
|
||||
const { history } = get();
|
||||
return history.slice(0, limit);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'pastel-color-history',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
6
lib/pastel/utils/cn.ts
Normal file
6
lib/pastel/utils/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
57
lib/pastel/utils/color.ts
Normal file
57
lib/pastel/utils/color.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Calculate relative luminance of a color
|
||||
* Based on WCAG 2.1 specification
|
||||
*/
|
||||
export function getRelativeLuminance(r: number, g: number, b: number): number {
|
||||
const [rs, gs, bs] = [r, g, b].map((c) => {
|
||||
const sRGB = c / 255;
|
||||
return sRGB <= 0.03928 ? sRGB / 12.92 : Math.pow((sRGB + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate contrast ratio between two colors
|
||||
* Returns ratio from 1 to 21
|
||||
*/
|
||||
export function getContrastRatio(
|
||||
fg: { r: number; g: number; b: number },
|
||||
bg: { r: number; g: number; b: number }
|
||||
): number {
|
||||
const l1 = getRelativeLuminance(fg.r, fg.g, fg.b);
|
||||
const l2 = getRelativeLuminance(bg.r, bg.g, bg.b);
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse hex color to RGB
|
||||
*/
|
||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a contrast ratio meets WCAG standards
|
||||
*/
|
||||
export function checkWCAGCompliance(ratio: number) {
|
||||
return {
|
||||
aa: {
|
||||
normalText: ratio >= 4.5,
|
||||
largeText: ratio >= 3,
|
||||
ui: ratio >= 3,
|
||||
},
|
||||
aaa: {
|
||||
normalText: ratio >= 7,
|
||||
largeText: ratio >= 4.5,
|
||||
},
|
||||
};
|
||||
}
|
||||
83
lib/pastel/utils/export.ts
Normal file
83
lib/pastel/utils/export.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Export utilities for color palettes
|
||||
*/
|
||||
|
||||
export interface ExportColor {
|
||||
name?: string;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export colors as CSS variables
|
||||
*/
|
||||
export function exportAsCSS(colors: ExportColor[]): string {
|
||||
const variables = colors
|
||||
.map((color, index) => {
|
||||
const name = color.name || `color-${index + 1}`;
|
||||
return ` --${name}: ${color.hex};`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `:root {\n${variables}\n}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export colors as SCSS variables
|
||||
*/
|
||||
export function exportAsSCSS(colors: ExportColor[]): string {
|
||||
return colors
|
||||
.map((color, index) => {
|
||||
const name = color.name || `color-${index + 1}`;
|
||||
return `$${name}: ${color.hex};`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export colors as Tailwind config
|
||||
*/
|
||||
export function exportAsTailwind(colors: ExportColor[]): string {
|
||||
const colorEntries = colors
|
||||
.map((color, index) => {
|
||||
const name = color.name || `color-${index + 1}`;
|
||||
return ` '${name}': '${color.hex}',`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `module.exports = {\n theme: {\n extend: {\n colors: {\n${colorEntries}\n },\n },\n },\n};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export colors as JSON
|
||||
*/
|
||||
export function exportAsJSON(colors: ExportColor[]): string {
|
||||
const colorObjects = colors.map((color, index) => ({
|
||||
name: color.name || `color-${index + 1}`,
|
||||
hex: color.hex,
|
||||
}));
|
||||
|
||||
return JSON.stringify({ colors: colorObjects }, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export colors as JavaScript array
|
||||
*/
|
||||
export function exportAsJavaScript(colors: ExportColor[]): string {
|
||||
const colorArray = colors.map((c) => `'${c.hex}'`).join(', ');
|
||||
return `const colors = [${colorArray}];`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download text as file
|
||||
*/
|
||||
export function downloadAsFile(content: string, filename: string, mimeType: string = 'text/plain') {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
70
lib/storage/favorites.ts
Normal file
70
lib/storage/favorites.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
const FAVORITES_KEY = 'figlet-ui-favorites';
|
||||
const RECENT_FONTS_KEY = 'figlet-ui-recent-fonts';
|
||||
const MAX_RECENT = 10;
|
||||
|
||||
export function getFavorites(): string[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(FAVORITES_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function addFavorite(fontName: string): void {
|
||||
const favorites = getFavorites();
|
||||
if (!favorites.includes(fontName)) {
|
||||
favorites.push(fontName);
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites));
|
||||
}
|
||||
}
|
||||
|
||||
export function removeFavorite(fontName: string): void {
|
||||
const favorites = getFavorites();
|
||||
const filtered = favorites.filter(f => f !== fontName);
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
export function isFavorite(fontName: string): boolean {
|
||||
return getFavorites().includes(fontName);
|
||||
}
|
||||
|
||||
export function toggleFavorite(fontName: string): boolean {
|
||||
const isCurrentlyFavorite = isFavorite(fontName);
|
||||
if (isCurrentlyFavorite) {
|
||||
removeFavorite(fontName);
|
||||
} else {
|
||||
addFavorite(fontName);
|
||||
}
|
||||
return !isCurrentlyFavorite;
|
||||
}
|
||||
|
||||
export function getRecentFonts(): string[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(RECENT_FONTS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function addRecentFont(fontName: string): void {
|
||||
let recent = getRecentFonts();
|
||||
|
||||
// Remove if already exists
|
||||
recent = recent.filter(f => f !== fontName);
|
||||
|
||||
// Add to beginning
|
||||
recent.unshift(fontName);
|
||||
|
||||
// Keep only MAX_RECENT items
|
||||
recent = recent.slice(0, MAX_RECENT);
|
||||
|
||||
localStorage.setItem(RECENT_FONTS_KEY, JSON.stringify(recent));
|
||||
}
|
||||
53
lib/storage/history.ts
Normal file
53
lib/storage/history.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
export interface HistoryItem {
|
||||
id: string;
|
||||
text: string;
|
||||
font: string;
|
||||
result: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const HISTORY_KEY = 'figlet-ui-history';
|
||||
const MAX_HISTORY = 10;
|
||||
|
||||
export function getHistory(): HistoryItem[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(HISTORY_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function addToHistory(text: string, font: string, result: string): void {
|
||||
let history = getHistory();
|
||||
|
||||
const newItem: HistoryItem = {
|
||||
id: `${Date.now()}-${Math.random()}`,
|
||||
text,
|
||||
font,
|
||||
result,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Add to beginning
|
||||
history.unshift(newItem);
|
||||
|
||||
// Keep only MAX_HISTORY items
|
||||
history = history.slice(0, MAX_HISTORY);
|
||||
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||
}
|
||||
|
||||
export function clearHistory(): void {
|
||||
localStorage.removeItem(HISTORY_KEY);
|
||||
}
|
||||
|
||||
export function removeHistoryItem(id: string): void {
|
||||
const history = getHistory();
|
||||
const filtered = history.filter(item => item.id !== id);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(filtered));
|
||||
}
|
||||
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';
|
||||
}
|
||||
38
lib/utils/animations.ts
Normal file
38
lib/utils/animations.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Animation utility classes and keyframes
|
||||
|
||||
export const fadeIn = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
};
|
||||
|
||||
export const slideUp = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
};
|
||||
|
||||
export const slideDown = {
|
||||
initial: { opacity: 0, y: -20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: 20 },
|
||||
};
|
||||
|
||||
export const scaleIn = {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.95 },
|
||||
};
|
||||
|
||||
export const staggerChildren = {
|
||||
animate: {
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const staggerItem = {
|
||||
initial: { opacity: 0, y: 10 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
};
|
||||
6
lib/utils/cn.ts
Normal file
6
lib/utils/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
18
lib/utils/debounce.ts
Normal file
18
lib/utils/debounce.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
};
|
||||
}
|
||||
4
lib/utils/index.ts
Normal file
4
lib/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './cn';
|
||||
export * from './debounce';
|
||||
export * from './urlSharing';
|
||||
export * from './animations';
|
||||
64
lib/utils/urlSharing.ts
Normal file
64
lib/utils/urlSharing.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
export interface ShareableState {
|
||||
text: string;
|
||||
font: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode text and font to URL parameters
|
||||
*/
|
||||
export function encodeToUrl(text: string, font: string): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (text) {
|
||||
params.set('text', text);
|
||||
}
|
||||
|
||||
if (font && font !== 'Standard') {
|
||||
params.set('font', font);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode URL parameters to get text and font
|
||||
*/
|
||||
export function decodeFromUrl(): ShareableState | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const text = params.get('text');
|
||||
const font = params.get('font');
|
||||
|
||||
if (!text && !font) return null;
|
||||
|
||||
return {
|
||||
text: text || '',
|
||||
font: font || 'Standard',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the URL without reloading the page
|
||||
*/
|
||||
export function updateUrl(text: string, font: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const url = encodeToUrl(text, font);
|
||||
const newUrl = url ? `${window.location.pathname}${url}` : window.location.pathname;
|
||||
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shareable URL
|
||||
*/
|
||||
export function getShareableUrl(text: string, font: string): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
|
||||
const query = encodeToUrl(text, font);
|
||||
return `${window.location.origin}${window.location.pathname}${query}`;
|
||||
}
|
||||
Reference in New Issue
Block a user