diff --git a/src/app.tsx b/src/app.tsx index 98dca31..afd4516 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -31,12 +31,6 @@ function AppLayout(props: { children: any }) { let lastScrollY = 0; const SCROLL_THRESHOLD = 100; - // Compute left margin reactively - const leftMargin = () => { - const isMobile = typeof window !== "undefined" && window.innerWidth < 768; - return isMobile ? 0 : leftBarSize(); - }; - createEffect(() => { const handleResize = () => { const isMobile = window.innerWidth < 768; // md breakpoint @@ -47,9 +41,7 @@ function AppLayout(props: { children: any }) { setRightBarVisible(true); } - // On mobile, leftbar overlays (don't subtract its size) - const leftOffset = isMobile ? 0 : leftBarSize(); - const newWidth = window.innerWidth - leftOffset - rightBarSize(); + const newWidth = window.innerWidth - leftBarSize() - rightBarSize(); setCenterWidth(newWidth); }; @@ -63,10 +55,7 @@ function AppLayout(props: { children: any }) { // Recalculate when bar sizes change (visibility or actual resize) createEffect(() => { - const isMobile = window.innerWidth < 768; - // On mobile, leftbar overlays (don't subtract its size) - const leftOffset = isMobile ? 0 : leftBarSize(); - const newWidth = window.innerWidth - leftOffset - rightBarSize(); + const newWidth = window.innerWidth - leftBarSize() - rightBarSize(); setCenterWidth(newWidth); }); @@ -170,31 +159,13 @@ function AppLayout(props: { children: any }) { return ( <> - -
- -
-
- -
+
{ - const isMobile = window.innerWidth < 768; - if (isMobile && leftBarVisible()) { - setLeftBarVisible(false); - } + "margin-left": `${leftBarSize()}px` }} > }> diff --git a/src/components/ActivityHeatmap.tsx b/src/components/ActivityHeatmap.tsx index c3ceacb..1666e8b 100644 --- a/src/components/ActivityHeatmap.tsx +++ b/src/components/ActivityHeatmap.tsx @@ -74,15 +74,15 @@ export const ActivityHeatmap: Component<{ {() => (
- {() => } + {() =>
}
)}
{/* Centered spinner overlay */} -
- +
+
} diff --git a/src/components/Bars.tsx b/src/components/Bars.tsx index 04a0734..1f63d7a 100644 --- a/src/components/Bars.tsx +++ b/src/components/Bars.tsx @@ -9,7 +9,6 @@ import { onCleanup } from "solid-js"; import { api } from "~/lib/api"; -import { TerminalSplash } from "./TerminalSplash"; import { insertSoftHyphens } from "~/lib/client-utils"; import GitHub from "./icons/GitHub"; import LinkedIn from "./icons/LinkedIn"; @@ -397,13 +396,15 @@ export function LeftBar() { fallback={ {() => ( -
-
- - - +
+
+
+ + +
+
- +
)} diff --git a/src/components/RecentCommits.tsx b/src/components/RecentCommits.tsx index ebf6c33..1a747c7 100644 --- a/src/components/RecentCommits.tsx +++ b/src/components/RecentCommits.tsx @@ -1,7 +1,6 @@ import { Component, For, Show } from "solid-js"; import { Typewriter } from "./Typewriter"; import { SkeletonText, SkeletonBox } from "./SkeletonLoader"; -import { formatRelativeDate } from "~/lib/date-utils"; interface Commit { sha: string; @@ -17,6 +16,28 @@ export const RecentCommits: Component<{ title: string; loading?: boolean; }> = (props) => { + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 60) { + return `${diffMins}m ago`; + } else if (diffHours < 24) { + return `${diffHours}h ago`; + } else if (diffDays < 7) { + return `${diffDays}d ago`; + } else { + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric" + }); + } + }; + return (

{props.title}

@@ -69,7 +90,7 @@ export const RecentCommits: Component<{
- {formatRelativeDate(commit.date)} + {formatDate(commit.date)}
diff --git a/src/context/bars.tsx b/src/context/bars.tsx index 4b2cf06..49063cc 100644 --- a/src/context/bars.tsx +++ b/src/context/bars.tsx @@ -1,5 +1,6 @@ import { Accessor, createContext, useContext, createMemo } from "solid-js"; import { createSignal } from "solid-js"; +import { hapticFeedback } from "~/lib/client-utils"; const BarsContext = createContext<{ leftBarSize: Accessor; @@ -97,11 +98,14 @@ export function BarsProvider(props: { children: any }) { return barsInitialized() ? syncedBarSize() : naturalSize; }); + // Wrap visibility setters with haptic feedback const setLeftBarVisible = (visible: boolean) => { + hapticFeedback(50); _setLeftBarVisible(visible); }; const setRightBarVisible = (visible: boolean) => { + hapticFeedback(50); _setRightBarVisible(visible); }; return ( diff --git a/src/lib/comment-utils.ts b/src/lib/comment-utils.ts index 2f09c4a..8dc7118 100644 --- a/src/lib/comment-utils.ts +++ b/src/lib/comment-utils.ts @@ -205,8 +205,7 @@ export function debounce any>( // ============================================================================ /** - * Validates that a comment body meets requirements (client-side UX only) - * Server validation is in src/server/api/schemas/validation.ts + * Validates that a comment body meets requirements */ export function isValidCommentBody(body: string): boolean { return body.trim().length > 0 && body.length <= 10000; diff --git a/src/lib/date-utils.ts b/src/lib/date-utils.ts index a0e134a..5abbdc6 100644 --- a/src/lib/date-utils.ts +++ b/src/lib/date-utils.ts @@ -1,62 +1,18 @@ /** - * Client-side Date Utilities - * - * ⚠️ DEPRECATED: For new code, use server-side date utilities in src/server/date-utils.ts - * - * This function is kept for backward compatibility with existing client code. - * Server-side code should use src/server/date-utils.ts instead. - * - * Note: This previously had a hardcoded +4 hour offset which was incorrect. - * Now properly returns UTC time. - */ - -/** - * Get current UTC date/time formatted for SQL insert - * - * @deprecated Use server-side getSQLFormattedDate from ~/server/utils instead - * @returns SQL-formatted date string (YYYY-MM-DD HH:MM:SS) in UTC + * Formats current date to match SQL datetime format + * Note: Adds 4 hours to match server timezone (EST) + * Returns format: YYYY-MM-DD HH:MM:SS */ export function getSQLFormattedDate(): string { const date = new Date(); + date.setHours(date.getHours() + 4); - const year = date.getUTCFullYear(); - const month = `${date.getUTCMonth() + 1}`.padStart(2, "0"); - const day = `${date.getUTCDate()}`.padStart(2, "0"); - const hours = `${date.getUTCHours()}`.padStart(2, "0"); - const minutes = `${date.getUTCMinutes()}`.padStart(2, "0"); - const seconds = `${date.getUTCSeconds()}`.padStart(2, "0"); + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + const hours = `${date.getHours()}`.padStart(2, "0"); + const minutes = `${date.getMinutes()}`.padStart(2, "0"); + const seconds = `${date.getSeconds()}`.padStart(2, "0"); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } - -/** - * Format a date for relative display (client-side) - * e.g., "5m ago", "2h ago", "Dec 19" - * - * @param dateString SQL date string or ISO string - * @returns Formatted relative time string - */ -export function formatRelativeDate(dateString: string): string { - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) { - return "just now"; - } else if (diffMins < 60) { - return `${diffMins}m ago`; - } else if (diffHours < 24) { - return `${diffHours}h ago`; - } else if (diffDays < 7) { - return `${diffDays}d ago`; - } else { - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: diffDays > 365 ? "numeric" : undefined - }); - } -} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index d7a064a..8338f41 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -1,18 +1,9 @@ /** - * Client-Side Validation Utilities (UX Only - NOT Security) - * - * ⚠️ IMPORTANT: These functions are for user experience only! - * - * Server-side validation in src/server/api/schemas/validation.ts is the - * source of truth and security boundary. These client functions provide - * instant feedback before submission but can be bypassed. - * - * Always rely on server validation for security. + * Form validation utilities */ /** - * Validate email format (client-side UX only) - * Server validation is in src/server/api/schemas/validation.ts + * Validate email format */ export function isValidEmail(email: string): boolean { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -20,8 +11,7 @@ export function isValidEmail(email: string): boolean { } /** - * Validate password strength (client-side UX only) - * Server validation is in src/server/api/schemas/validation.ts + * Validate password strength */ export function validatePassword(password: string): { isValid: boolean; @@ -51,8 +41,7 @@ export function validatePassword(password: string): { } /** - * Check if two passwords match (client-side UX only) - * Server validation is in src/server/api/schemas/validation.ts + * Check if two passwords match */ export function passwordsMatch( password: string, @@ -62,8 +51,7 @@ export function passwordsMatch( } /** - * Validate display name (client-side UX only) - * Server validation is in src/server/api/schemas/validation.ts + * Validate display name */ export function isValidDisplayName(name: string): boolean { return name.trim().length >= 1 && name.trim().length <= 50; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index b18ad48..4daf2b2 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -42,9 +42,6 @@ export default function Home() { here (github). - - Check the side bar(s) for more info and important links. -
Some of my recent projects:
@@ -92,7 +89,7 @@ export default function Home() {
{/* Life and Lineage */} -
+
My mobile game:
{ - const { email, password } = input; + const { email, password, passwordConfirmation } = input; + + if (password !== passwordConfirmation) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "passwordMismatch" + }); + } const passwordHash = await hashPassword(password); const conn = ConnectionFactory(); @@ -405,7 +411,13 @@ export const authRouter = createTRPCRouter({ // Email/password login emailPasswordLogin: publicProcedure - .input(loginSchema) + .input( + z.object({ + email: z.string().email(), + password: z.string(), + rememberMe: z.boolean().optional() + }) + ) .mutation(async ({ input, ctx }) => { const { email, password, rememberMe } = input; @@ -465,7 +477,7 @@ export const authRouter = createTRPCRouter({ requestEmailLinkLogin: publicProcedure .input( z.object({ - email: emailSchema, + email: z.string().email(), rememberMe: z.boolean().optional() }) ) @@ -570,7 +582,7 @@ export const authRouter = createTRPCRouter({ // Request password reset requestPasswordReset: publicProcedure - .input(z.object({ email: emailSchema })) + .input(z.object({ email: z.string().email() })) .mutation(async ({ input, ctx }) => { const { email } = input; @@ -669,9 +681,22 @@ export const authRouter = createTRPCRouter({ // Reset password with token resetPassword: publicProcedure - .input(passwordResetSchema) + .input( + z.object({ + token: z.string(), + newPassword: z.string().min(8), + newPasswordConfirmation: z.string().min(8) + }) + ) .mutation(async ({ input, ctx }) => { - const { token, newPassword } = input; + const { token, newPassword, newPasswordConfirmation } = input; + + if (newPassword !== newPasswordConfirmation) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Password Mismatch" + }); + } try { // Verify JWT token @@ -718,7 +743,7 @@ export const authRouter = createTRPCRouter({ // Resend email verification resendEmailVerification: publicProcedure - .input(z.object({ email: emailSchema })) + .input(z.object({ email: z.string().email() })) .mutation(async ({ input, ctx }) => { const { email } = input; @@ -827,3 +852,4 @@ export const authRouter = createTRPCRouter({ return { success: true }; }) }); + diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 3316a72..1625e11 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -11,13 +11,6 @@ import { import { setCookie } from "vinxi/http"; import type { User } from "~/types/user"; import { toUserProfile } from "~/types/user"; -import { - updateEmailSchema, - updateDisplayNameSchema, - passwordChangeSchema, - passwordSetSchema, - deleteAccountSchema -} from "~/server/api/schemas/validation"; export const userRouter = createTRPCRouter({ // Get current user profile @@ -50,7 +43,7 @@ export const userRouter = createTRPCRouter({ // Update email updateEmail: publicProcedure - .input(updateEmailSchema) + .input(z.object({ email: z.string().email() })) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -87,7 +80,7 @@ export const userRouter = createTRPCRouter({ // Update display name updateDisplayName: publicProcedure - .input(updateDisplayNameSchema) + .input(z.object({ displayName: z.string().min(1).max(50) })) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -118,9 +111,7 @@ export const userRouter = createTRPCRouter({ // Update profile image updateProfileImage: publicProcedure - .input( - z.object({ imageUrl: z.string().url().optional().or(z.literal("")) }) - ) + .input(z.object({ imageUrl: z.string() })) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -151,7 +142,13 @@ export const userRouter = createTRPCRouter({ // Change password (requires old password) changePassword: publicProcedure - .input(passwordChangeSchema) + .input( + z.object({ + oldPassword: z.string(), + newPassword: z.string().min(8), + newPasswordConfirmation: z.string().min(8) + }) + ) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -162,7 +159,14 @@ export const userRouter = createTRPCRouter({ }); } - const { oldPassword, newPassword } = input; + const { oldPassword, newPassword, newPasswordConfirmation } = input; + + if (newPassword !== newPasswordConfirmation) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Password Mismatch" + }); + } const conn = ConnectionFactory(); const res = await conn.execute({ @@ -220,7 +224,12 @@ export const userRouter = createTRPCRouter({ // Set password (for OAuth users who don't have password) setPassword: publicProcedure - .input(passwordSetSchema) + .input( + z.object({ + newPassword: z.string().min(8), + newPasswordConfirmation: z.string().min(8) + }) + ) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -231,7 +240,14 @@ export const userRouter = createTRPCRouter({ }); } - const { password } = input; + const { newPassword, newPasswordConfirmation } = input; + + if (newPassword !== newPasswordConfirmation) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Password Mismatch" + }); + } const conn = ConnectionFactory(); const res = await conn.execute({ @@ -256,7 +272,7 @@ export const userRouter = createTRPCRouter({ } // Set password - const passwordHash = await hashPassword(password); + const passwordHash = await hashPassword(newPassword); await conn.execute({ sql: "UPDATE User SET password_hash = ? WHERE id = ?", args: [passwordHash, userId] @@ -277,7 +293,7 @@ export const userRouter = createTRPCRouter({ // Delete account (anonymize data) deleteAccount: publicProcedure - .input(deleteAccountSchema) + .input(z.object({ password: z.string() })) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); diff --git a/src/server/api/schemas/validation.ts b/src/server/api/schemas/validation.ts deleted file mode 100644 index 0ee4e96..0000000 --- a/src/server/api/schemas/validation.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { z } from "zod"; - -/** - * Validation Schemas for tRPC Procedures - * - * These schemas are the source of truth for server-side validation. - * Client-side validation (src/lib/validation.ts) is optional for UX only. - */ - -// ============================================================================ -// Base Schemas -// ============================================================================ - -/** - * Email validation schema - * - Must be valid email format - * - Min 3 chars, max 255 chars - * - Trimmed and lowercased automatically - */ -export const emailSchema = z - .string() - .trim() - .toLowerCase() - .email("Invalid email address") - .min(3, "Email too short") - .max(255, "Email too long"); - -/** - * Password validation schema - * - Minimum 8 characters - * - Maximum 128 characters - * - Can add additional complexity requirements if needed - */ -export const passwordSchema = z - .string() - .min(8, "Password must be at least 8 characters") - .max(128, "Password too long"); - -/** - * Display name validation schema - * - Minimum 1 character (after trim) - * - Maximum 50 characters - */ -export const displayNameSchema = z - .string() - .trim() - .min(1, "Display name is required") - .max(50, "Display name too long"); - -/** - * Comment body validation schema - * - Minimum 1 character (after trim) - * - Maximum 10,000 characters - */ -export const commentBodySchema = z - .string() - .trim() - .min(1, "Comment cannot be empty") - .max(10000, "Comment too long (max 10,000 characters)"); - -// ============================================================================ -// Composed Schemas -// ============================================================================ - -/** - * Email/password login schema - */ -export const loginSchema = z.object({ - email: emailSchema, - password: passwordSchema, - rememberMe: z.boolean().optional() -}); - -/** - * Email/password registration schema with password confirmation - */ -export const registrationSchema = z - .object({ - email: emailSchema, - password: passwordSchema, - passwordConfirmation: passwordSchema, - displayName: displayNameSchema.optional() - }) - .refine((data) => data.password === data.passwordConfirmation, { - message: "Passwords do not match", - path: ["passwordConfirmation"] - }); - -/** - * Password change schema (requires old password) - */ -export const passwordChangeSchema = z - .object({ - oldPassword: passwordSchema, - newPassword: passwordSchema, - newPasswordConfirmation: passwordSchema - }) - .refine((data) => data.newPassword === data.newPasswordConfirmation, { - message: "New passwords do not match", - path: ["newPasswordConfirmation"] - }) - .refine((data) => data.oldPassword !== data.newPassword, { - message: "New password must be different from old password", - path: ["newPassword"] - }); - -/** - * Password reset schema (no old password required) - */ -export const passwordResetSchema = z - .object({ - token: z.string(), - newPassword: passwordSchema, - newPasswordConfirmation: passwordSchema - }) - .refine((data) => data.newPassword === data.newPasswordConfirmation, { - message: "Passwords do not match", - path: ["newPasswordConfirmation"] - }); - -/** - * Password set schema (for OAuth users setting password first time) - */ -export const passwordSetSchema = z - .object({ - password: passwordSchema, - passwordConfirmation: passwordSchema - }) - .refine((data) => data.password === data.passwordConfirmation, { - message: "Passwords do not match", - path: ["passwordConfirmation"] - }); - -/** - * Update email schema - */ -export const updateEmailSchema = z.object({ - email: emailSchema -}); - -/** - * Update display name schema - */ -export const updateDisplayNameSchema = z.object({ - displayName: displayNameSchema -}); - -/** - * Account deletion schema - */ -export const deleteAccountSchema = z.object({ - password: passwordSchema -}); diff --git a/src/server/date-utils.ts b/src/server/date-utils.ts deleted file mode 100644 index 634c37d..0000000 --- a/src/server/date-utils.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Server-side Date/Time Utilities - * - * All dates are handled in UTC to avoid timezone issues. - * Database should store dates in UTC format. - */ - -/** - * Get current UTC date/time formatted for SQL insert - * - * @returns SQL-formatted date string (YYYY-MM-DD HH:MM:SS) in UTC - * @example - * getSQLFormattedDate() // "2024-12-19 15:30:45" - */ -export function getSQLFormattedDate(): string { - const now = new Date(); - - const year = now.getUTCFullYear(); - const month = String(now.getUTCMonth() + 1).padStart(2, "0"); - const day = String(now.getUTCDate()).padStart(2, "0"); - const hours = String(now.getUTCHours()).padStart(2, "0"); - const minutes = String(now.getUTCMinutes()).padStart(2, "0"); - const seconds = String(now.getUTCSeconds()).padStart(2, "0"); - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; -} - -/** - * Format a Date object for SQL insert - * - * @param date Date object to format - * @returns SQL-formatted date string (YYYY-MM-DD HH:MM:SS) in UTC - * @example - * formatDateForSQL(new Date()) // "2024-12-19 15:30:45" - */ -export function formatDateForSQL(date: Date): string { - const year = date.getUTCFullYear(); - const month = String(date.getUTCMonth() + 1).padStart(2, "0"); - const day = String(date.getUTCDate()).padStart(2, "0"); - const hours = String(date.getUTCHours()).padStart(2, "0"); - const minutes = String(date.getUTCMinutes()).padStart(2, "0"); - const seconds = String(date.getUTCSeconds()).padStart(2, "0"); - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; -} - -/** - * Parse a client-provided date string to Date object - * - * @param dateString ISO 8601 date string from client - * @returns Date object or null if invalid - * @example - * parseClientDate("2024-12-19T15:30:45.000Z") // Date object - * parseClientDate("invalid") // null - */ -export function parseClientDate(dateString: string): Date | null { - try { - const date = new Date(dateString); - return isNaN(date.getTime()) ? null : date; - } catch { - return null; - } -} - -/** - * Format a date for display with relative time - * Handles "X minutes ago", "X hours ago", etc. - * - * @param dateString SQL date string or ISO string - * @returns Formatted relative time string - * @example - * formatRelativeDate("2024-12-19 15:00:00") // "30m ago" (if now is 15:30) - */ -export function formatRelativeDate(dateString: string): string { - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) { - return "just now"; - } else if (diffMins < 60) { - return `${diffMins}m ago`; - } else if (diffHours < 24) { - return `${diffHours}h ago`; - } else if (diffDays < 7) { - return `${diffDays}d ago`; - } else { - // For older dates, return formatted date - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: diffDays > 365 ? "numeric" : undefined - }); - } -} - -/** - * Format a date for display - * - * @param dateString SQL date string or ISO string - * @returns Formatted date string (e.g., "Dec 19, 2024") - * @example - * formatDateForDisplay("2024-12-19 15:30:45") // "Dec 19, 2024" - */ -export function formatDateForDisplay(dateString: string): string { - const date = new Date(dateString); - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric" - }); -} - -/** - * Get timestamp in milliseconds (UTC) - * - * @returns Current timestamp in ms - */ -export function getCurrentTimestamp(): number { - return Date.now(); -} diff --git a/src/server/utils.ts b/src/server/utils.ts index b21f6ef..1279add 100644 --- a/src/server/utils.ts +++ b/src/server/utils.ts @@ -17,12 +17,3 @@ export { export { hashPassword, checkPassword } from "./password"; export { sendEmailVerification, LINEAGE_JWT_EXPIRY } from "./email"; - -export { - getSQLFormattedDate, - formatDateForSQL, - parseClientDate, - formatRelativeDate, - formatDateForDisplay, - getCurrentTimestamp -} from "./date-utils";