diff --git a/external/resume b/external/resume index 1a781e0..40bdfde 160000 --- a/external/resume +++ b/external/resume @@ -1 +1 @@ -Subproject commit 1a781e0566d0ac6f73d7e0d5d04c6b668aefc7d8 +Subproject commit 40bdfde2d2461bb0d530d84c32cc1a7531d7b344 diff --git a/scripts/copy-resume.js b/scripts/copy-resume.js index f99618a..d22349b 100644 --- a/scripts/copy-resume.js +++ b/scripts/copy-resume.js @@ -7,10 +7,10 @@ const destDir = 'public/resume'; // Ensure destination directory exists fs.mkdirSync(destDir, { recursive: true }); -// Copy PDF and HTML files +// Copy PDF files const files = fs.readdirSync(srcDir); for (const file of files) { - if (file.endsWith('.pdf') || file.endsWith('.html')) { + if (file.endsWith('.pdf')) { fs.copyFileSync(path.join(srcDir, file), path.join(destDir, file)); console.log(`Copied ${file}`); } diff --git a/src/__tests__/components/TerminalEmulator.test.tsx b/src/__tests__/components/TerminalEmulator.test.tsx index ea761f3..3b10606 100644 --- a/src/__tests__/components/TerminalEmulator.test.tsx +++ b/src/__tests__/components/TerminalEmulator.test.tsx @@ -1,6 +1,12 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import TerminalEmulator from '@/components/terminal/TerminalEmulator'; +import { $isAppReady } from '@/stores/app-store'; +import { + $terminalHistory, + $terminalHistoryVisibleIdx, + $terminalPromptRef, +} from '@/stores/terminal-store'; import type { CommandEntry } from '@/types/terminal'; import { Command } from '@/types/terminal'; @@ -21,6 +27,12 @@ vi.mock('@/stores/terminal-store', () => ({ initializeTerminal: vi.fn(), })); +vi.mock('@/stores/app-store', () => ({ + $isAppReady: { + get: vi.fn(() => true), + }, +})); + vi.mock('next-intl', () => ({ useTranslations: vi.fn(() => (key: string) => key), })); @@ -43,23 +55,17 @@ vi.mock('@/components/cmd-outputs/UnknownCmdOutput', () => ({ )), })); -import { - $terminalHistory, - $terminalHistoryVisibleIdx, - $terminalPromptRef, - initializeTerminal, -} from '@/stores/terminal-store'; - describe('TerminalEmulator', () => { const mockHistoryGet = vi.mocked($terminalHistory.get); const mockHistoryVisibleIdxGet = vi.mocked($terminalHistoryVisibleIdx.get); const mockPromptRefSet = vi.mocked($terminalPromptRef.set); - const _mockInitializeTerminal = vi.mocked(initializeTerminal); + const mockIsAppReadyGet = vi.mocked($isAppReady.get); beforeEach(() => { vi.clearAllMocks(); mockHistoryGet.mockReturnValue([]); mockHistoryVisibleIdxGet.mockReturnValue(0); + mockIsAppReadyGet.mockReturnValue(true); }); afterEach(() => { diff --git a/src/components/LoadSequence.tsx b/src/components/LoadSequence.tsx index b90af41..56d9ea6 100644 --- a/src/components/LoadSequence.tsx +++ b/src/components/LoadSequence.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'; import { useEffect, useMemo, useState } from 'react'; +import { $isAppMounted, $isAppReady } from '@/stores/app-store'; import { $isDarkMode } from '@/stores/theme-store'; interface LoadSequenceProps { @@ -10,13 +11,22 @@ interface LoadSequenceProps { export default function LoadSequence({ children }: LoadSequenceProps) { const [currentStep, setCurrentStep] = useState(0); - const [isLoading, setIsLoading] = useState(true); + const [animationDone, setAnimationDone] = useState(false); const isDarkMode = useStore($isDarkMode); + const isAppMounted = useStore($isAppMounted); + + const isLoading = !animationDone || !isAppMounted; useEffect(() => { document.body.style.visibility = 'visible'; }, []); + useEffect(() => { + if (!isLoading) { + $isAppReady.set(true); + } + }, [isLoading]); + const steps = useMemo( () => [ { message: '> Detecting theme preference...', delay: 600 }, @@ -40,7 +50,7 @@ export default function LoadSequence({ children }: LoadSequenceProps) { return () => clearTimeout(timer); } else { - setIsLoading(false); + setAnimationDone(true); } }, [currentStep, steps]); @@ -48,47 +58,53 @@ export default function LoadSequence({ children }: LoadSequenceProps) { 'opacity-0 animate-[showInstant_0s_ease-in-out_forwards]'; const stepHeight = 'h-[24px] leading-6'; - if (!isLoading && children) { - return <>{children}; - } - return ( -
- -
- {/* Static HTML steps that display immediately */} -
- {'> Connected to IPFS'} -
-
- {'> Loading core chunks'} -
+ <> +
+ {children} +
-
- {steps.slice(0, currentStep).map((step) => ( -
- {step.message} + {isLoading && ( +
+ +
+
+ {'> Connected to IPFS'}
- ))} -
-
- +
+ {'> Loading core chunks'} +
+ +
+ {steps.slice(0, currentStep).map((step) => ( +
+ {step.message} +
+ ))} +
+
+ +
+
-
-
+ )} + ); } diff --git a/src/components/cmd-outputs/ContactOutput.tsx b/src/components/cmd-outputs/ContactOutput.tsx index 3a61fd9..9467d70 100644 --- a/src/components/cmd-outputs/ContactOutput.tsx +++ b/src/components/cmd-outputs/ContactOutput.tsx @@ -49,12 +49,6 @@ export default function ContactOutput() { href: 'https://x.com/nipsysdev', displayText: '@nipsysdev', }, - { - icon: PaperPlaneTiltIcon, - labelKey: 'telegram', - href: 'https://t.me/nipsysdev', - displayText: '@nipsysdev', - }, { icon: ButterflyIcon, labelKey: 'bluesky', @@ -83,6 +77,20 @@ export default function ContactOutput() { displayText: '@nipsys:nips.im', copyable: true, }, + { + icon: ChatTeardropTextIcon, + labelKey: 'signal', + href: 'https://signal.me/#eu/4FynXZ6lCD-qaR0x_CfvmEGVVtnprCVT4YRzyVrn7GVNB71oVHFAL6aP2soYBAI4', + displayText: 'nipsysdev.12', + copyable: true, + }, + { + icon: PaperPlaneTiltIcon, + labelKey: 'telegram', + href: 'https://t.me/nipsysdev', + displayText: '@nipsysdev', + copyable: true, + }, { icon: EnvelopeIcon, labelKey: 'email', diff --git a/src/components/cmd-outputs/ResumeOutput.tsx b/src/components/cmd-outputs/ResumeOutput.tsx index 2766af2..0068c85 100644 --- a/src/components/cmd-outputs/ResumeOutput.tsx +++ b/src/components/cmd-outputs/ResumeOutput.tsx @@ -1,163 +1,20 @@ 'use client'; -import { useStore } from '@nanostores/react'; -import { Badge, Button, ScrollArea, Typography } from '@nipsys/lsd'; +import { Badge, Button, Typography } from '@nipsys/lsd'; +import Link from 'next/link'; import { useLocale, useTranslations } from 'next-intl'; -import { useEffect, useRef, useState } from 'react'; -import { $isDarkMode } from '@/stores/theme-store'; const RESUME_PATHS = { - en: { - pdf: '/resume/Xavier-SALINIERE_resume.EN.pdf', - html: '/resume/Xavier-SALINIERE_resume.EN.html', - }, - fr: { - pdf: '/resume/Xavier-SALINIERE_resume.FR.pdf', - html: '/resume/Xavier-SALINIERE_resume.FR.html', - }, + en: '/resume/Xavier-SALINIERE_resume.EN.pdf', + fr: '/resume/Xavier-SALINIERE_resume.FR.pdf', }; -async function fetchHtml(locale: 'en' | 'fr'): Promise { - const response = await fetch(RESUME_PATHS[locale].html); - if (!response.ok) { - throw new Error(`Failed to fetch resume HTML: ${response.status}`); - } - return response.text(); -} - -function extractContent(html: string): { - body: string; - style: string; - script: string; -} { - const styleMatch = html.match(/]*>([\s\S]*?)<\/style>/i); - const scriptMatch = html.match( - /]*type="module"[^>]*>([\s\S]*?)<\/script>/i, - ); - const bodyMatch = html.match(/]*>([\s\S]*?)<\/body>/i); - - return { - style: styleMatch?.[1] || '', - script: scriptMatch?.[1] || '', - body: bodyMatch?.[1] || '', - }; -} - -function ResumeHtml({ htmlContent }: { htmlContent: string }) { - const containerRef = useRef(null); - const shadowRef = useRef(null); - const isDarkMode = useStore($isDarkMode); - const [initialized, setInitialized] = useState(false); - - useEffect(() => { - if (!containerRef.current) return; - - if (!shadowRef.current) { - shadowRef.current = containerRef.current.attachShadow({ mode: 'open' }); - } - const shadow = shadowRef.current; - - // Clear previous content before re-rendering - shadow.innerHTML = ''; - - const { style, body, script } = extractContent(htmlContent); - - const hostStyles = ` - :host { - display: block; - width: 100%; - min-height: 100%; - } - :host(.dark) { - --color-background: var(--color-background-dark, #191e23); - --color-dimmed: var(--color-dimmed-dark, #23282d); - --color-primary: var(--color-primary-dark, #fbfbfc); - --color-secondary: var(--color-secondary-dark, #ccd0d4); - --color-accent: var(--color-accent-dark, #00a0d2); - } - :host(.light) { - --color-background: var(--color-background-light, #ffffff); - --color-dimmed: var(--color-dimmed-light, #f3f4f5); - --color-primary: var(--color-primary-light, #191e23); - --color-secondary: var(--color-secondary-light, #6c7781); - --color-accent: var(--color-accent-light, #0073aa); - } - .resume-wrapper { - background: var(--color-background); - padding: 1.5rem; - min-height: 100%; - } - `; - - const styleEl = document.createElement('style'); - styleEl.textContent = hostStyles + style; - shadow.appendChild(styleEl); - - const wrapper = document.createElement('div'); - wrapper.className = 'resume-wrapper'; - wrapper.innerHTML = body; - shadow.appendChild(wrapper); - - if (script) { - // SECURITY: Only allow trusted scripts from bundled resume HTML assets. - // The script content comes from our own /public/resume/*.html files, - // not from user input. This is safe because we control the source. - const wrappedScript = ` - const originalDefine = customElements.define.bind(customElements); - customElements.define = (name, constructor, options) => { - if (!customElements.get(name)) { - originalDefine(name, constructor, options); - } - }; - ${script} - `; - const scriptEl = document.createElement('script'); - scriptEl.type = 'module'; - scriptEl.textContent = wrappedScript; - shadow.appendChild(scriptEl); - } - - setInitialized(true); - - // Cleanup: Clear shadow root content on unmount to prevent memory leak - return () => { - if (shadowRef.current) { - shadowRef.current.innerHTML = ''; - } - }; - }, [htmlContent]); - - useEffect(() => { - if (!containerRef.current || !initialized) return; - - const host = containerRef.current; - if (isDarkMode) { - host.classList.add('dark'); - host.classList.remove('light'); - } else { - host.classList.add('light'); - host.classList.remove('dark'); - } - }, [isDarkMode, initialized]); - - return
; -} - export default function ResumeOutput() { const locale = useLocale(); const t = useTranslations('Resume'); - const [html, setHtml] = useState(''); - const [error, setError] = useState(null); - - useEffect(() => { - fetchHtml(locale as 'en' | 'fr') - .then(setHtml) - .catch((err) => { - console.error('Failed to load resume:', err); - setError('Failed to load resume'); - }); - }, [locale]); + const currentLocale = locale === 'fr' ? 'fr' : 'en'; + const currentPdfPath = RESUME_PATHS[currentLocale]; return (
@@ -170,7 +27,7 @@ export default function ResumeOutput() {
- {t('viewing')} {locale.toUpperCase()} {t('version')} + {t('viewing')} {currentLocale.toUpperCase()} {t('version')}
@@ -181,12 +38,17 @@ export default function ResumeOutput() { asChild data-prevent-terminal-focus > - + {t('downloadEN')} - @@ -197,21 +59,50 @@ export default function ResumeOutput() { className="w-full max-w-6xl h-[600px] border border-(--lsd-color-border) rounded-(--lsd-shape-sm) overflow-hidden" data-prevent-terminal-focus > - - {error ? ( -
- {error} -
- ) : html ? ( - - ) : ( -
- Loading... -
- )} -
+