Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions src/hooks/useAnimatedScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useCallback } from "react";
import { useReducedMotion } from "framer-motion";

interface AnimatedScrollOptions {
/**
* Duration of the fade animation in milliseconds
* @default 400
*/
duration?: number;
/**
* Offset to apply to the final scroll position (useful for sticky headers)
* @default 0
*/
offset?: number;
}

/**
* Custom hook that provides animated scrolling with fade effects
* to make content appear to "come to you" without showing the scroll journey
*/
export function useAnimatedScroll(options: AnimatedScrollOptions = {}) {
const { duration = 400, offset = 0 } = options;
const prefersReducedMotion = useReducedMotion();
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor consistency: most components in this repo coerce useReducedMotion() to a strict boolean via useReducedMotion() ?? false (e.g. src/components/ScrollSpy.tsx:24). Here it’s assigned directly, so the type/behaviour may differ from the rest of the codebase depending on framer-motion’s return value. Consider matching the existing pattern for consistency.

Suggested change
const prefersReducedMotion = useReducedMotion();
const prefersReducedMotion = useReducedMotion() ?? false;

Copilot uses AI. Check for mistakes.

const scrollToElement = useCallback(
(targetId: string) => {
const element = document.getElementById(targetId);
if (!element) return;

// For reduced motion, use instant browser scroll
if (prefersReducedMotion) {
element.scrollIntoView({ behavior: "auto", block: "start" });
return;
}

const main = document.querySelector("main");
if (!main) {
element.scrollIntoView({ behavior: "auto", block: "start" });
return;
}

const targetPosition =
element.getBoundingClientRect().top + window.scrollY + offset;

Comment on lines +30 to +44
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

offset is ignored when prefersReducedMotion is true (and also when falling back to scrollIntoView because <main> isn’t found). Since call sites pass offset: -80 for a sticky header, reduced-motion users can end up with the target section hidden under the header. Consider computing the target Y position and using window.scrollTo({ top: ..., behavior: "auto" }) in these branches as well so offset is consistently honoured.

Suggested change
// For reduced motion, use instant browser scroll
if (prefersReducedMotion) {
element.scrollIntoView({ behavior: "auto", block: "start" });
return;
}
const main = document.querySelector("main");
if (!main) {
element.scrollIntoView({ behavior: "auto", block: "start" });
return;
}
const targetPosition =
element.getBoundingClientRect().top + window.scrollY + offset;
const targetPosition =
element.getBoundingClientRect().top + window.scrollY + offset;
// For reduced motion, use instant browser scroll while still respecting offset
if (prefersReducedMotion) {
window.scrollTo({ top: targetPosition, behavior: "auto" });
return;
}
const main = document.querySelector("main");
if (!main) {
window.scrollTo({ top: targetPosition, behavior: "auto" });
return;
}

Copilot uses AI. Check for mistakes.
// Phase 1: Fade out current view
main.style.transition = `opacity ${duration}ms ease-out, transform ${duration}ms ease-out`;
main.style.opacity = "0";
main.style.transform = "scale(0.95)";

// Phase 2: After fade out, scroll instantly and fade in
setTimeout(() => {
// Instant scroll while content is hidden
window.scrollTo({
top: targetPosition,
behavior: "auto",
});

// Trigger reflow to ensure style changes are applied
// eslint-disable-next-line @typescript-eslint/no-unused-expressions

// Phase 3: Fade in the target content with scale effect
requestAnimationFrame(() => {
Comment on lines +58 to +62
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s a "Trigger reflow" comment followed by an eslint-disable-next-line @typescript-eslint/no-unused-expressions, but no statement on the next line. This is misleading and makes it unclear whether a reflow was intended; either add the actual reflow trigger (e.g., by reading a layout property) or remove the comment/disable directive.

Copilot uses AI. Check for mistakes.
main.style.transform = "scale(1.02)";
main.style.opacity = "1";

// Settle to final state
setTimeout(() => {
main.style.transform = "scale(1)";

// Clean up after animation completes
setTimeout(() => {
main.style.transition = "";
main.style.transform = "";
main.style.opacity = "";
}, duration);
}, 50);
});
}, duration);
},
[duration, offset, prefersReducedMotion],
);

const scrollToTop = useCallback(() => {
if (prefersReducedMotion) {
window.scrollTo({ top: 0, behavior: "auto" });
return;
}

const main = document.querySelector("main");
if (!main) {
window.scrollTo({ top: 0, behavior: "auto" });
return;
}

// Phase 1: Fade out
main.style.transition = `opacity ${duration}ms ease-out, transform ${duration}ms ease-out`;
main.style.opacity = "0";
main.style.transform = "scale(0.95)";

// Phase 2: Scroll and fade in
setTimeout(() => {
window.scrollTo({ top: 0, behavior: "auto" });

// Trigger reflow to ensure style changes are applied
// eslint-disable-next-line @typescript-eslint/no-unused-expressions

requestAnimationFrame(() => {
main.style.transform = "scale(1.02)";
Comment on lines +104 to +108
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above in scrollToTop: the reflow comment and eslint-disable-next-line directive aren’t followed by any statement. This should either perform the intended reflow or be removed to avoid dead/misleading lint directives.

Copilot uses AI. Check for mistakes.
main.style.opacity = "1";

setTimeout(() => {
main.style.transform = "scale(1)";

setTimeout(() => {
main.style.transition = "";
main.style.transform = "";
main.style.opacity = "";
}, duration);
}, 50);
});
}, duration);
}, [duration, prefersReducedMotion]);

return { scrollToElement, scrollToTop };
}
52 changes: 52 additions & 0 deletions src/hooks/useBodyScrollLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useEffect } from "react";

/**
* Custom hook to lock/unlock body scroll
* @param isLocked - Whether the body scroll should be locked
*/
export function useBodyScrollLock(isLocked: boolean) {
useEffect(() => {
// Early return if not in browser environment
if (globalThis.window === undefined || typeof document === "undefined") {
return undefined;
}

if (!isLocked) return undefined;

// Store original body overflow and padding
const originalOverflow = document.body.style.overflow;
const originalPaddingRight = document.body.style.paddingRight;

// Get scrollbar width to prevent layout shift
const scrollbarWidth =
globalThis.window.innerWidth - document.documentElement.clientWidth;

// Lock scroll
document.body.style.overflow = "hidden";

// Add padding to compensate for scrollbar disappearance
if (scrollbarWidth > 0) {
// Get computed padding in pixels
const computedStyle = globalThis.window.getComputedStyle(document.body);
const currentPadding = Number.parseFloat(computedStyle.paddingRight) || 0;
document.body.style.paddingRight = `${currentPadding + scrollbarWidth}px`;
}

// Cleanup function to restore original state
return () => {
// Restore or remove overflow property
if (originalOverflow) {
document.body.style.overflow = originalOverflow;
} else {
document.body.style.removeProperty("overflow");
}

// Restore or remove padding-right property
if (originalPaddingRight) {
document.body.style.paddingRight = originalPaddingRight;
} else {
document.body.style.removeProperty("padding-right");
}
};
}, [isLocked]);
}
2 changes: 2 additions & 0 deletions src/sections/ContactSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ const TURNSTILE_SCRIPT_SRC =
// Default placeholders for template usage. Set VITE_TURNSTILE_SITE_KEY or
// VITE_TURNSTYLE_SITE in your environment to enable captcha checks.
const DEFAULT_TURNSTYLE_SITE_KEY = "";
const rawTurnstileSiteKey =
(import.meta.env.VITE_TURNSTILE_SITE_KEY ??
import.meta.env.VITE_TURNSTYLE_SITE ??
DEFAULT_TURNSTYLE_SITE_KEY ??
"") ||
Expand Down
4 changes: 2 additions & 2 deletions src/sections/SkillsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ function SkillsBoard({
}

export function SkillsSection() {
const { t } = useTranslation();
const { data: remoteSkills, debugAttributes: skillsDebugAttributes } =
useRemoteData<string[]>({
resource: SKILLS_RESOURCE,
Expand Down Expand Up @@ -221,10 +222,9 @@ export function SkillsSection() {
<div className="card-surface space-y-8">
<SectionHeader
id="skills"
icon="material-symbols:auto-awesome-rounded"
label={t.skills.title}
eyebrow="Strengths"
eyebrow={t.skills.eyebrow}
eyebrow="Strengths"
/>
<SkillsBoard
skills={skills}
Expand Down
55 changes: 55 additions & 0 deletions src/translations/ca.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"nav": {
"home": "Inici",
"about": "Jo",
"experience": "Feina",
"education": "Educació",
"certifications": "Llicència",
"projects": "Afició",
"skills": "Habilitats",
"contact": "Contacte"
},
"hero": {
"greeting": "Hola, sóc",
"name": "Kiya Rose",
"wave": "👋",
"headline": "Treballadors de TI aprenent suport sanitari",
"description": "Dedico el meu temps lliure a construir projectes de codi mentre em preparo per ajudar equips a temps complet en TI de salut i suport tècnic."
},
"about": {
"title": "Sobre mi",
"eyebrow": "Perfil",
"description": "Sóc un professional de TI que busca adquirir experiència en administració d'oficines mentre persegueixo el meu Certificat de Facturació i Codificació. M'encanten els jocs d'un sol jugador amb història i pensar massa. També m'agrada escriure de tant en tant :3"
},
"experience": {
"title": "Experiència",
"eyebrow": "Trajectòria Professional"
},
"education": {
"title": "Educació",
"eyebrow": "Formació Acadèmica"
},
"certifications": {
"title": "Certificacions",
"eyebrow": "Credencials Professionals"
},
"projects": {
"title": "Projectes Personals",
"eyebrow": "Projectes Personals"
},
"skills": {
"title": "Habilitats",
"eyebrow": "Capacitats Tècniques"
},
"contact": {
"title": "Contacte",
"eyebrow": "Posa't en Contacte"
},
"theme": {
"darkMode": "Mode fosc",
"lightMode": "Mode clar"
},
"language": {
"select": "Idioma"
}
}
55 changes: 55 additions & 0 deletions src/translations/fr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"nav": {
"home": "Accueil",
"about": "Moi",
"experience": "Expérience",
"education": "Éducation",
"certifications": "Certifications",
"projects": "Passe-temps",
"skills": "Capacités",
"contact": "Courrier"
},
"hero": {
"greeting": "Bonjour, je suis",
"name": "Kiya Rose",
"wave": "👋",
"headline": "Professionnelle en Formation en TI Santé et Support",
"description": "Je passe mon temps libre à créer des projets de code tout en me préparant à aider des équipes à temps plein dans les domaines de la TI santé et du support technique."
},
"about": {
"title": "À propos",
"eyebrow": "Profil",
"description": "Je suis une professionnelle en TI cherchant à acquérir de l'expérience en administration de bureau tout en poursuivant mon Certificat de Facturation et Codage. J'adore les jeux solo avec une histoire et réfléchir trop. J'aime aussi écrire de temps en temps :3"
},
"experience": {
"title": "Expérience",
"eyebrow": "Parcours Professionnel"
},
"education": {
"title": "Éducation",
"eyebrow": "Formation Académique"
},
"certifications": {
"title": "Certifications",
"eyebrow": "Accréditations Professionnelles"
},
"projects": {
"title": "Projets Personnels",
"eyebrow": "Projets Personnels"
},
"skills": {
"title": "Compétences",
"eyebrow": "Capacités Techniques"
},
"contact": {
"title": "Contact",
"eyebrow": "Entrer en Contact"
},
"theme": {
"darkMode": "Mode sombre",
"lightMode": "Mode clair"
},
"language": {
"select": "Langue"
}
}
55 changes: 55 additions & 0 deletions src/translations/ja.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"nav": {
"home": "ホーム",
"about": "プロフ",
"experience": "経験",
"education": "学",
"certifications": "資格",
"projects": "趣味のプロジ",
"skills": "スキル",
"contact": "お問"
},
"hero": {
"greeting": "こんにちは、私は",
"name": "Kiya Rose",
"wave": "👋",
"headline": "ヘルスケアIT&サポート専門家トレーニング中",
"description": "ヘルスケアITとテクニカルサポートでフルタイムでチームを支援する準備をしながら、自由時間にコードプロジェクトを構築しています。"
},
"about": {
"title": "プロフィール",
"eyebrow": "プロフィール",
"description": "私は請求とコーディングの資格取得を目指しながら、オフィス管理の経験を積もうとしているIT専門家です。シングルプレイヤーのストーリーゲームと考えすぎることが大好きです。時々書くことも好きです :3"
},
"experience": {
"title": "経験",
"eyebrow": "職業の歩み"
},
"education": {
"title": "学歴",
"eyebrow": "学術的背景"
},
"certifications": {
"title": "資格",
"eyebrow": "専門資格"
},
"projects": {
"title": "趣味のプロジェクト",
"eyebrow": "個人プロジェクト"
},
"skills": {
"title": "スキル",
"eyebrow": "技術的能力"
},
"contact": {
"title": "お問い合わせ",
"eyebrow": "連絡する"
},
"theme": {
"darkMode": "ダーク",
"lightMode": "ライト"
},
"language": {
"select": "言語"
}
}
Loading
Loading