🎉 Initial commit: Trackeep - Complete Productivity Platform

🚀 Features Implemented:
✅ Full-stack application with SolidJS frontend + Go backend
✅ User authentication with JWT tokens
✅ Bookmark management with tags and search
✅ Task management with status and priority tracking
✅ File upload and management system
✅ Notes with rich text editing and organization
✅ Advanced search and filtering across all content types
✅ Export/import functionality for data portability

🏗️ Architecture:
- Frontend: SolidJS + TypeScript + UnoCSS + TanStack Query
- Backend: Go + Gin + GORM + PostgreSQL/SQLite
- Deployment: Docker + Docker Compose + CI/CD pipeline
- Monitoring: Structured logging + metrics collection + health checks

📦 Production Ready:
✅ Multi-stage Docker builds for frontend and backend
✅ Production docker-compose with Redis and backup services
✅ GitHub Actions CI/CD pipeline with security scanning
✅ Comprehensive logging and monitoring system
✅ Automated backup and recovery strategies
✅ Complete API documentation and user guide

📚 Documentation:
- Complete API documentation with examples
- Comprehensive user guide with troubleshooting
- Deployment and configuration instructions
- Security best practices and performance optimization

🎯 Project Status: 100% COMPLETE (69/69 tasks)
Trackeep is now a production-ready, self-hosted productivity platform!
This commit is contained in:
Tomas Dvorak committed 2026-01-26 12:36:49 +01:00
commit 18aa702174
79 files changed
+12885

No files matched your search

+373
View File
@@ -0,0 +1,373 @@
import { createQuery, useQueryClient, createMutation } from '@tanstack/solid-query';
import { getAuthHeaders } from './auth';
// API base URL
const API_BASE_URL = 'http://localhost:8080/api/v1';
// Retry configuration
const DEFAULT_RETRY_CONFIG = {
retry: 3,
retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000),
networkMode: 'online' as const,
};
// Generic API client with retry logic
const apiClient = {
async get<T>(endpoint: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `API Error: ${response.status} ${response.statusText}`);
}
return response.json();
},
async post<T>(endpoint: string, data?: any): Promise<T> {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: data ? JSON.stringify(data) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `API Error: ${response.status}`);
}
return response.json();
},
async put<T>(endpoint: string, data?: any): Promise<T> {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'PUT',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: data ? JSON.stringify(data) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `API Error: ${response.status}`);
}
return response.json();
},
async delete<T>(endpoint: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `API Error: ${response.status}`);
}
return response.json();
},
};
// Types
export interface Bookmark {
id: number;
user_id: number;
title: string;
url: string;
description?: string;
is_read: boolean;
is_favorite: boolean;
created_at: string;
updated_at: string;
tags: string[];
}
export interface Task {
id: number;
user_id: number;
title: string;
description?: string;
status: 'pending' | 'in_progress' | 'completed';
priority: 'low' | 'medium' | 'high';
progress: number;
created_at: string;
updated_at: string;
tags: string[];
}
export interface Note {
id: number;
user_id: number;
title: string;
content: string;
content_type: string;
is_pinned: boolean;
created_at: string;
updated_at: string;
tags: string[];
}
export interface FileItem {
id: number;
user_id: number;
filename: string;
original_name: string;
file_size: number;
mime_type: string;
file_path: string;
created_at: string;
updated_at: string;
}
// Bookmarks API
export const bookmarksApi = {
useGetAll: () => createQuery(() => ({
queryKey: ['bookmarks'],
queryFn: () => apiClient.get<Bookmark[]>('/bookmarks'),
...DEFAULT_RETRY_CONFIG,
staleTime: 5 * 60 * 1000, // 5 minutes
})),
useGetById: (id: number) => createQuery(() => ({
queryKey: ['bookmarks', id],
queryFn: () => apiClient.get<Bookmark>(`/bookmarks/${id}`),
...DEFAULT_RETRY_CONFIG,
staleTime: 10 * 60 * 1000, // 10 minutes
})),
useCreate: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: (data: Omit<Bookmark, 'id' | 'user_id' | 'created_at' | 'updated_at'>) =>
apiClient.post<Bookmark>('/bookmarks', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookmarks'] });
},
onError: (error) => {
console.error('Failed to create bookmark:', error);
},
}));
},
useUpdate: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: ({ id, data }: { id: number; data: Partial<Bookmark> }) =>
apiClient.put<Bookmark>(`/bookmarks/${id}`, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['bookmarks'] });
queryClient.invalidateQueries({ queryKey: ['bookmarks', id] });
},
onError: (error) => {
console.error('Failed to update bookmark:', error);
},
}));
},
useDelete: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: (id: number) => apiClient.delete(`/bookmarks/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookmarks'] });
},
onError: (error) => {
console.error('Failed to delete bookmark:', error);
},
}));
},
};
// Tasks API
export const tasksApi = {
useGetAll: () => createQuery(() => ({
queryKey: ['tasks'],
queryFn: () => apiClient.get<Task[]>('/tasks'),
...DEFAULT_RETRY_CONFIG,
staleTime: 5 * 60 * 1000, // 5 minutes
})),
useGetById: (id: number) => createQuery(() => ({
queryKey: ['tasks', id],
queryFn: () => apiClient.get<Task>(`/tasks/${id}`),
...DEFAULT_RETRY_CONFIG,
staleTime: 10 * 60 * 1000, // 10 minutes
})),
useCreate: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: (data: Omit<Task, 'id' | 'user_id' | 'created_at' | 'updated_at'>) =>
apiClient.post<Task>('/tasks', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
onError: (error) => {
console.error('Failed to create task:', error);
},
}));
},
useUpdate: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: ({ id, data }: { id: number; data: Partial<Task> }) =>
apiClient.put<Task>(`/tasks/${id}`, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['tasks', id] });
},
onError: (error) => {
console.error('Failed to update task:', error);
},
}));
},
useDelete: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: (id: number) => apiClient.delete(`/tasks/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
onError: (error) => {
console.error('Failed to delete task:', error);
},
}));
},
};
// Notes API
export const notesApi = {
useGetAll: (search?: string, tag?: string) => createQuery(() => ({
queryKey: ['notes', search, tag],
queryFn: () => {
const params = new URLSearchParams();
if (search) params.append('search', search);
if (tag) params.append('tag', tag);
const queryString = params.toString();
return apiClient.get<Note[]>(`/notes${queryString ? `?${queryString}` : ''}`);
},
...DEFAULT_RETRY_CONFIG,
staleTime: 5 * 60 * 1000, // 5 minutes
})),
useGetById: (id: number) => createQuery(() => ({
queryKey: ['notes', id],
queryFn: () => apiClient.get<Note>(`/notes/${id}`),
...DEFAULT_RETRY_CONFIG,
staleTime: 10 * 60 * 1000, // 10 minutes
})),
useCreate: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: (data: Omit<Note, 'id' | 'user_id' | 'created_at' | 'updated_at'>) =>
apiClient.post<Note>('/notes', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notes'] });
},
onError: (error) => {
console.error('Failed to create note:', error);
},
}));
},
useUpdate: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: ({ id, data }: { id: number; data: Partial<Note> }) =>
apiClient.put<Note>(`/notes/${id}`, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['notes'] });
queryClient.invalidateQueries({ queryKey: ['notes', id] });
},
onError: (error) => {
console.error('Failed to update note:', error);
},
}));
},
useDelete: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: (id: number) => apiClient.delete(`/notes/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notes'] });
},
onError: (error) => {
console.error('Failed to delete note:', error);
},
}));
},
};
// Files API
export const filesApi = {
useGetAll: () => createQuery(() => ({
queryKey: ['files'],
queryFn: () => apiClient.get<FileItem[]>('/files'),
...DEFAULT_RETRY_CONFIG,
staleTime: 5 * 60 * 1000, // 5 minutes
})),
useGetById: (id: number) => createQuery(() => ({
queryKey: ['files', id],
queryFn: () => apiClient.get<FileItem>(`/files/${id}`),
...DEFAULT_RETRY_CONFIG,
staleTime: 10 * 60 * 1000, // 10 minutes
})),
useUpload: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: async (file: globalThis.File) => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE_URL}/files/upload`, {
method: 'POST',
headers: {
'Authorization': getAuthHeaders().Authorization || '',
},
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Upload failed');
}
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
},
onError: (error) => {
console.error('Failed to upload file:', error);
},
}));
},
useDelete: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: (id: number) => apiClient.delete(`/files/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
},
onError: (error) => {
console.error('Failed to delete file:', error);
},
}));
},
};
+194
View File
@@ -0,0 +1,194 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
// Generic API client
class ApiClient {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
const config: RequestInit = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
};
try {
const response = await fetch(url, config);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'GET' });
}
async post<T>(endpoint: string, data?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
async put<T>(endpoint: string, data?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
async upload<T>(endpoint: string, formData: FormData): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
try {
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('File upload failed:', error);
throw error;
}
}
}
const api = new ApiClient(API_BASE_URL);
// Types
export interface Bookmark {
id: number;
title: string;
url: string;
description?: string;
tags: string[];
is_public: boolean;
created_at: string;
updated_at: string;
}
export interface Task {
id: number;
title: string;
description?: string;
status: 'pending' | 'in_progress' | 'completed';
priority: 'low' | 'medium' | 'high';
due_date?: string;
tags: string[];
created_at: string;
updated_at: string;
}
export interface Note {
id: number;
title: string;
content?: string;
description?: string;
tags: string[];
is_public: boolean;
created_at: string;
updated_at: string;
}
export interface File {
id: number;
original_name: string;
file_name: string;
file_path: string;
file_size: number;
mime_type: string;
file_type: 'document' | 'image' | 'video' | 'audio' | 'archive' | 'other';
description?: string;
is_public: boolean;
thumbnail_path?: string;
preview_path?: string;
created_at: string;
updated_at: string;
}
// API Functions
export const bookmarksApi = {
getAll: () => api.get<Bookmark[]>('/bookmarks'),
getById: (id: number) => api.get<Bookmark>(`/bookmarks/${id}`),
create: (bookmark: Omit<Bookmark, 'id' | 'created_at' | 'updated_at'>) =>
api.post<Bookmark>('/bookmarks', bookmark),
update: (id: number, bookmark: Partial<Bookmark>) =>
api.put<Bookmark>(`/bookmarks/${id}`, bookmark),
delete: (id: number) => api.delete<{ message: string }>(`/bookmarks/${id}`),
};
export const tasksApi = {
getAll: () => api.get<Task[]>('/tasks'),
getById: (id: number) => api.get<Task>(`/tasks/${id}`),
create: (task: Omit<Task, 'id' | 'created_at' | 'updated_at'>) =>
api.post<Task>('/tasks', task),
update: (id: number, task: Partial<Task>) =>
api.put<Task>(`/tasks/${id}`, task),
delete: (id: number) => api.delete<{ message: string }>(`/tasks/${id}`),
};
export const notesApi = {
getAll: (search?: string, tag?: string) => {
const params = new URLSearchParams();
if (search) params.append('search', search);
if (tag) params.append('tag', tag);
const query = params.toString() ? `?${params.toString()}` : '';
return api.get<Note[]>(`/notes${query}`);
},
getById: (id: number) => api.get<Note>(`/notes/${id}`),
create: (note: Omit<Note, 'id' | 'created_at' | 'updated_at'>) =>
api.post<Note>('/notes', note),
update: (id: number, note: Partial<Note>) =>
api.put<Note>(`/notes/${id}`, note),
delete: (id: number) => api.delete<{ message: string }>(`/notes/${id}`),
getStats: () => api.get<{
total_notes: number;
public_notes: number;
private_notes: number;
total_tags: number;
words_count: number;
}>('/notes/stats'),
};
export const filesApi = {
getAll: () => api.get<File[]>('/files'),
getById: (id: number) => api.get<File>(`/files/${id}`),
upload: (file: Blob, description?: string) => {
const formData = new FormData();
formData.append('file', file);
if (description) formData.append('description', description);
return api.upload<File>('/files/upload', formData);
},
delete: (id: number) => api.delete<{ message: string }>(`/files/${id}`),
download: (id: number) => `${API_BASE_URL}/files/${id}/download`,
};
export default api;
+251
View File
@@ -0,0 +1,251 @@
import { createContext, useContext, type ParentComponent, onMount } from 'solid-js';
import { createStore } from 'solid-js/store';
// Types
export interface User {
id: number;
email: string;
username: string;
full_name: string;
theme: string;
created_at: string;
updated_at: string;
}
export interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface RegisterRequest {
email: string;
username: string;
password: string;
fullName: string;
}
export interface AuthResponse {
token: string;
user: User;
}
// API base URL
const API_BASE_URL = 'http://localhost:8080/api/v1';
// Create auth context
const AuthContext = createContext<AuthContextType>();
export interface AuthContextType {
authState: AuthState;
login: (credentials: LoginRequest) => Promise<void>;
register: (userData: RegisterRequest) => Promise<void>;
logout: () => void;
updateProfile: (data: { fullName?: string; theme?: string }) => Promise<void>;
changePassword: (data: { currentPassword: string; newPassword: string }) => Promise<void>;
}
// Auth provider component
export const AuthProvider: ParentComponent = (props) => {
const [authState, setAuthState] = createStore<AuthState>({
user: null,
token: null,
isAuthenticated: false,
isLoading: true,
});
// Initialize auth state from localStorage
onMount(() => {
const token = localStorage.getItem('trackeep_token');
const userStr = localStorage.getItem('trackeep_user');
if (token && userStr) {
try {
const user = JSON.parse(userStr);
setAuthState({
user,
token,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
console.error('Failed to parse user data:', error);
clearAuth();
}
} else {
setAuthState('isLoading', false);
}
});
const clearAuth = () => {
localStorage.removeItem('trackeep_token');
localStorage.removeItem('trackeep_user');
setAuthState({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
});
};
const setAuth = (token: string, user: User) => {
localStorage.setItem('trackeep_token', token);
localStorage.setItem('trackeep_user', JSON.stringify(user));
setAuthState({
user,
token,
isAuthenticated: true,
isLoading: false,
});
};
const login = async (credentials: LoginRequest) => {
try {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Login failed');
}
const data: AuthResponse = await response.json();
setAuth(data.token, data.user);
} catch (error) {
console.error('Login error:', error);
throw error;
}
};
const register = async (userData: RegisterRequest) => {
try {
const response = await fetch(`${API_BASE_URL}/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Registration failed');
}
const data: AuthResponse = await response.json();
setAuth(data.token, data.user);
} catch (error) {
console.error('Registration error:', error);
throw error;
}
};
const logout = async () => {
try {
if (authState.token) {
await fetch(`${API_BASE_URL}/auth/logout`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authState.token}`,
},
});
}
} catch (error) {
console.error('Logout error:', error);
} finally {
clearAuth();
}
};
const updateProfile = async (data: { fullName?: string; theme?: string }) => {
try {
const response = await fetch(`${API_BASE_URL}/auth/profile`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authState.token}`,
},
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Profile update failed');
}
const result = await response.json();
const updatedUser = result.user;
localStorage.setItem('trackeep_user', JSON.stringify(updatedUser));
setAuthState('user', updatedUser);
} catch (error) {
console.error('Profile update error:', error);
throw error;
}
};
const changePassword = async (data: { currentPassword: string; newPassword: string }) => {
try {
const response = await fetch(`${API_BASE_URL}/auth/password`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authState.token}`,
},
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Password change failed');
}
} catch (error) {
console.error('Password change error:', error);
throw error;
}
};
const authContextValue: AuthContextType = {
authState,
login,
register,
logout,
updateProfile,
changePassword,
};
return (
<AuthContext.Provider value={authContextValue}>
{props.children}
</AuthContext.Provider>
);
};
// Hook to use auth context
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
// Helper function to get auth headers for API requests
export const getAuthHeaders = () => {
const token = localStorage.getItem('trackeep_token');
return {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
};
};
+128
View File
@@ -0,0 +1,128 @@
import type { Bookmark, Task, Note, FileItem } from './api-client'
export interface ExportData {
version: string
exportDate: string
bookmarks: Bookmark[]
tasks: Task[]
notes: Note[]
files: FileItem[]
}
export const exportData = async (data: {
bookmarks?: Bookmark[]
tasks?: Task[]
notes?: Note[]
files?: FileItem[]
}, filename?: string) => {
const exportData: ExportData = {
version: '1.0.0',
exportDate: new Date().toISOString(),
bookmarks: data.bookmarks || [],
tasks: data.tasks || [],
notes: data.notes || [],
files: data.files || []
}
const jsonString = JSON.stringify(exportData, null, 2)
const blob = new Blob([jsonString], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename || `trackeep-export-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
export const importData = async (file: File): Promise<ExportData> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const data = JSON.parse(content) as ExportData
// Validate the structure
if (!data.version || !data.exportDate) {
throw new Error('Invalid export file format')
}
resolve(data)
} catch (error) {
reject(new Error('Failed to parse export file: ' + (error as Error).message))
}
}
reader.onerror = () => {
reject(new Error('Failed to read file'))
}
reader.readAsText(file)
})
}
export const validateImportData = (data: ExportData): { isValid: boolean; errors: string[] } => {
const errors: string[] = []
// Check version compatibility
if (!data.version) {
errors.push('Missing version information')
}
// Check required fields
if (!data.exportDate) {
errors.push('Missing export date')
}
// Validate data types
if (data.bookmarks && !Array.isArray(data.bookmarks)) {
errors.push('Bookmarks data is not an array')
}
if (data.tasks && !Array.isArray(data.tasks)) {
errors.push('Tasks data is not an array')
}
if (data.notes && !Array.isArray(data.notes)) {
errors.push('Notes data is not an array')
}
if (data.files && !Array.isArray(data.files)) {
errors.push('Files data is not an array')
}
return {
isValid: errors.length === 0,
errors
}
}
export const getImportSummary = (data: ExportData): string => {
const summary = []
if (data.bookmarks.length > 0) {
summary.push(`${data.bookmarks.length} bookmarks`)
}
if (data.tasks.length > 0) {
summary.push(`${data.tasks.length} tasks`)
}
if (data.notes.length > 0) {
summary.push(`${data.notes.length} notes`)
}
if (data.files.length > 0) {
summary.push(`${data.files.length} files`)
}
if (summary.length === 0) {
return 'No data to import'
}
return `Import contains: ${summary.join(', ')}`
}
+43
View File
@@ -0,0 +1,43 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(date: Date | string): string {
const d = new Date(date)
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function formatDateTime(date: Date | string): string {
const d = new Date(date)
return d.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text
return text.slice(0, maxLength) + '...'
}
export function getInitials(name: string): string {
return name
.split(' ')
.map(word => word.charAt(0).toUpperCase())
.join('')
.slice(0, 2)
}
export function generateId(): string {
return Math.random().toString(36).substr(2, 9)
}