Archived
- Add mobile/ directory with Expo React Native project - Create API client with JWT auth and token refresh using AsyncStorage - Implement AuthContext for login/register/logout flow - Add screens: Login, Register, Library, Search, Settings - Set up React Navigation with AuthStack and MainTabs - Create packages/shared/ with shared types and utilities - Add shared validation utilities (email, password strength) - Update root package.json workspaces to include mobile + shared - Add spec document docs/backend/009-expo-integration.md
117 lines
3.1 KiB
TypeScript
117 lines
3.1 KiB
TypeScript
/** Shared utility functions for Cloud Reader */
|
|
|
|
/**
|
|
* Format an ISO date string to a human-readable date.
|
|
*/
|
|
export function formatDate(iso: string): string {
|
|
const date = new Date(iso);
|
|
return date.toLocaleDateString("en-US", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Format a date as a relative time string (e.g., "2h ago", "3d ago").
|
|
*/
|
|
export function formatRelativeTime(iso: string): string {
|
|
const now = Date.now();
|
|
const then = new Date(iso).getTime();
|
|
const diffMs = now - then;
|
|
const seconds = Math.floor(diffMs / 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";
|
|
}
|
|
|
|
/**
|
|
* Validate an email address format.
|
|
*/
|
|
export function isValidEmail(email: string): boolean {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
}
|
|
|
|
/**
|
|
* Password strength check:
|
|
* - At least 8 characters
|
|
* - At least one uppercase letter
|
|
* - At least one lowercase letter
|
|
* - At least one digit
|
|
*/
|
|
export function isStrongPassword(password: string): boolean {
|
|
return (
|
|
password.length >= 8 &&
|
|
/[A-Z]/.test(password) &&
|
|
/[a-z]/.test(password) &&
|
|
/\d/.test(password)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if two passwords match.
|
|
*/
|
|
export function doPasswordsMatch(password: string, confirm: string): boolean {
|
|
return password === confirm;
|
|
}
|
|
|
|
/**
|
|
* Get a user-friendly reading status label.
|
|
*/
|
|
export function readingStatusLabel(status: string): string {
|
|
const labels: Record<string, string> = {
|
|
to_read: "To Read",
|
|
reading: "Reading",
|
|
finished: "Finished",
|
|
dnf: "Did Not Finish",
|
|
};
|
|
return labels[status] ?? status;
|
|
}
|
|
|
|
/**
|
|
* Format file size in bytes to a human-readable string.
|
|
*/
|
|
export function formatFileSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
// ---- API endpoint constants ----
|
|
|
|
export const API_ENDPOINTS = {
|
|
auth: {
|
|
login: "/api/auth/login/",
|
|
register: "/api/auth/register/",
|
|
tokenRefresh: "/api/auth/token/refresh/",
|
|
profile: "/api/auth/profile/",
|
|
},
|
|
books: {
|
|
list: "/api/books/",
|
|
detail: (id: number | string) => `/api/books/${id}/`,
|
|
genres: "/api/books/genres/",
|
|
authors: "/api/books/authors/",
|
|
},
|
|
ebooks: {
|
|
list: "/api/ebooks/",
|
|
detail: (id: number) => `/api/ebooks/${id}/`,
|
|
upload: "/api/ebooks/upload/",
|
|
toc: (id: number) => `/api/ebooks/${id}/toc/`,
|
|
content: (id: number, page: number) =>
|
|
`/api/ebooks/${id}/content/?page=${page}`,
|
|
progress: (id: number) => `/api/ebooks/${id}/progress/`,
|
|
settings: (id: number) => `/api/ebooks/${id}/settings/`,
|
|
},
|
|
annotations: {
|
|
list: "/api/annotations/",
|
|
bookmarks: "/api/annotations/bookmarks/",
|
|
notes: "/api/annotations/notes/",
|
|
bookmarkDetail: (id: string) => `/api/annotations/bookmarks/${id}/`,
|
|
noteDetail: (id: string) => `/api/annotations/notes/${id}/`,
|
|
},
|
|
} as const; |