diff --git a/src/app.tsx b/src/app.tsx index 0afcc90..98dca31 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -31,6 +31,12 @@ 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 @@ -41,7 +47,9 @@ function AppLayout(props: { children: any }) { setRightBarVisible(true); } - const newWidth = window.innerWidth - leftBarSize() - rightBarSize(); + // On mobile, leftbar overlays (don't subtract its size) + const leftOffset = isMobile ? 0 : leftBarSize(); + const newWidth = window.innerWidth - leftOffset - rightBarSize(); setCenterWidth(newWidth); }; @@ -55,7 +63,10 @@ function AppLayout(props: { children: any }) { // Recalculate when bar sizes change (visibility or actual resize) createEffect(() => { - const newWidth = window.innerWidth - leftBarSize() - rightBarSize(); + 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(); setCenterWidth(newWidth); }); @@ -177,7 +188,13 @@ function AppLayout(props: { children: any }) { class="bg-base relative min-h-screen rounded-t-lg shadow-2xl" style={{ width: `${centerWidth()}px`, - "margin-left": `${leftBarSize()}px` + "margin-left": `${leftMargin()}px` + }} + onClick={() => { + const isMobile = window.innerWidth < 768; + if (isMobile && leftBarVisible()) { + setLeftBarVisible(false); + } }} > }> diff --git a/src/components/RecentCommits.tsx b/src/components/RecentCommits.tsx index 1a747c7..ebf6c33 100644 --- a/src/components/RecentCommits.tsx +++ b/src/components/RecentCommits.tsx @@ -1,6 +1,7 @@ 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; @@ -16,28 +17,6 @@ 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}

@@ -90,7 +69,7 @@ export const RecentCommits: Component<{
- {formatDate(commit.date)} + {formatRelativeDate(commit.date)}
diff --git a/src/context/bars.tsx b/src/context/bars.tsx index 49063cc..4b2cf06 100644 --- a/src/context/bars.tsx +++ b/src/context/bars.tsx @@ -1,6 +1,5 @@ import { Accessor, createContext, useContext, createMemo } from "solid-js"; import { createSignal } from "solid-js"; -import { hapticFeedback } from "~/lib/client-utils"; const BarsContext = createContext<{ leftBarSize: Accessor; @@ -98,14 +97,11 @@ 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 8dc7118..2f09c4a 100644 --- a/src/lib/comment-utils.ts +++ b/src/lib/comment-utils.ts @@ -205,7 +205,8 @@ export function debounce any>( // ============================================================================ /** - * Validates that a comment body meets requirements + * Validates that a comment body meets requirements (client-side UX only) + * Server validation is in src/server/api/schemas/validation.ts */ 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 5abbdc6..a0e134a 100644 --- a/src/lib/date-utils.ts +++ b/src/lib/date-utils.ts @@ -1,18 +1,62 @@ /** - * 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 + * 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 */ export function getSQLFormattedDate(): string { const date = new Date(); - date.setHours(date.getHours() + 4); - 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"); + 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"); 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 8338f41..d7a064a 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -1,9 +1,18 @@ /** - * Form validation utilities + * 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. */ /** - * Validate email format + * Validate email format (client-side UX only) + * Server validation is in src/server/api/schemas/validation.ts */ export function isValidEmail(email: string): boolean { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -11,7 +20,8 @@ export function isValidEmail(email: string): boolean { } /** - * Validate password strength + * Validate password strength (client-side UX only) + * Server validation is in src/server/api/schemas/validation.ts */ export function validatePassword(password: string): { isValid: boolean; @@ -41,7 +51,8 @@ export function validatePassword(password: string): { } /** - * Check if two passwords match + * Check if two passwords match (client-side UX only) + * Server validation is in src/server/api/schemas/validation.ts */ export function passwordsMatch( password: string, @@ -51,7 +62,8 @@ export function passwordsMatch( } /** - * Validate display name + * Validate display name (client-side UX only) + * Server validation is in src/server/api/schemas/validation.ts */ 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 4daf2b2..b18ad48 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -42,6 +42,9 @@ export default function Home() { here (github). + + Check the side bar(s) for more info and important links. +
Some of my recent projects:
@@ -89,7 +92,7 @@ export default function Home() {
{/* Life and Lineage */} -
+
My mobile game:
{ - const { email, password, passwordConfirmation } = input; - - if (password !== passwordConfirmation) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "passwordMismatch" - }); - } + const { email, password } = input; const passwordHash = await hashPassword(password); const conn = ConnectionFactory(); @@ -411,13 +405,7 @@ export const authRouter = createTRPCRouter({ // Email/password login emailPasswordLogin: publicProcedure - .input( - z.object({ - email: z.string().email(), - password: z.string(), - rememberMe: z.boolean().optional() - }) - ) + .input(loginSchema) .mutation(async ({ input, ctx }) => { const { email, password, rememberMe } = input; @@ -477,7 +465,7 @@ export const authRouter = createTRPCRouter({ requestEmailLinkLogin: publicProcedure .input( z.object({ - email: z.string().email(), + email: emailSchema, rememberMe: z.boolean().optional() }) ) @@ -582,7 +570,7 @@ export const authRouter = createTRPCRouter({ // Request password reset requestPasswordReset: publicProcedure - .input(z.object({ email: z.string().email() })) + .input(z.object({ email: emailSchema })) .mutation(async ({ input, ctx }) => { const { email } = input; @@ -681,22 +669,9 @@ export const authRouter = createTRPCRouter({ // Reset password with token resetPassword: publicProcedure - .input( - z.object({ - token: z.string(), - newPassword: z.string().min(8), - newPasswordConfirmation: z.string().min(8) - }) - ) + .input(passwordResetSchema) .mutation(async ({ input, ctx }) => { - const { token, newPassword, newPasswordConfirmation } = input; - - if (newPassword !== newPasswordConfirmation) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Password Mismatch" - }); - } + const { token, newPassword } = input; try { // Verify JWT token @@ -743,7 +718,7 @@ export const authRouter = createTRPCRouter({ // Resend email verification resendEmailVerification: publicProcedure - .input(z.object({ email: z.string().email() })) + .input(z.object({ email: emailSchema })) .mutation(async ({ input, ctx }) => { const { email } = input; @@ -852,4 +827,3 @@ export const authRouter = createTRPCRouter({ return { success: true }; }) }); - diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 1625e11..3316a72 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -11,6 +11,13 @@ 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 @@ -43,7 +50,7 @@ export const userRouter = createTRPCRouter({ // Update email updateEmail: publicProcedure - .input(z.object({ email: z.string().email() })) + .input(updateEmailSchema) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -80,7 +87,7 @@ export const userRouter = createTRPCRouter({ // Update display name updateDisplayName: publicProcedure - .input(z.object({ displayName: z.string().min(1).max(50) })) + .input(updateDisplayNameSchema) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -111,7 +118,9 @@ export const userRouter = createTRPCRouter({ // Update profile image updateProfileImage: publicProcedure - .input(z.object({ imageUrl: z.string() })) + .input( + z.object({ imageUrl: z.string().url().optional().or(z.literal("")) }) + ) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -142,13 +151,7 @@ export const userRouter = createTRPCRouter({ // Change password (requires old password) changePassword: publicProcedure - .input( - z.object({ - oldPassword: z.string(), - newPassword: z.string().min(8), - newPasswordConfirmation: z.string().min(8) - }) - ) + .input(passwordChangeSchema) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -159,14 +162,7 @@ export const userRouter = createTRPCRouter({ }); } - const { oldPassword, newPassword, newPasswordConfirmation } = input; - - if (newPassword !== newPasswordConfirmation) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Password Mismatch" - }); - } + const { oldPassword, newPassword } = input; const conn = ConnectionFactory(); const res = await conn.execute({ @@ -224,12 +220,7 @@ export const userRouter = createTRPCRouter({ // Set password (for OAuth users who don't have password) setPassword: publicProcedure - .input( - z.object({ - newPassword: z.string().min(8), - newPasswordConfirmation: z.string().min(8) - }) - ) + .input(passwordSetSchema) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -240,14 +231,7 @@ export const userRouter = createTRPCRouter({ }); } - const { newPassword, newPasswordConfirmation } = input; - - if (newPassword !== newPasswordConfirmation) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Password Mismatch" - }); - } + const { password } = input; const conn = ConnectionFactory(); const res = await conn.execute({ @@ -272,7 +256,7 @@ export const userRouter = createTRPCRouter({ } // Set password - const passwordHash = await hashPassword(newPassword); + const passwordHash = await hashPassword(password); await conn.execute({ sql: "UPDATE User SET password_hash = ? WHERE id = ?", args: [passwordHash, userId] @@ -293,7 +277,7 @@ export const userRouter = createTRPCRouter({ // Delete account (anonymize data) deleteAccount: publicProcedure - .input(z.object({ password: z.string() })) + .input(deleteAccountSchema) .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 new file mode 100644 index 0000000..0ee4e96 --- /dev/null +++ b/src/server/api/schemas/validation.ts @@ -0,0 +1,153 @@ +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 new file mode 100644 index 0000000..634c37d --- /dev/null +++ b/src/server/date-utils.ts @@ -0,0 +1,124 @@ +/** + * 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 1279add..b21f6ef 100644 --- a/src/server/utils.ts +++ b/src/server/utils.ts @@ -17,3 +17,12 @@ export { export { hashPassword, checkPassword } from "./password"; export { sendEmailVerification, LINEAGE_JWT_EXPIRY } from "./email"; + +export { + getSQLFormattedDate, + formatDateForSQL, + parseClientDate, + formatRelativeDate, + formatDateForDisplay, + getCurrentTimestamp +} from "./date-utils";