├── src ├── setupTests.ts ├── vite-env.d.ts ├── main.tsx ├── App.css ├── App.test.tsx ├── contexts │ ├── ThemeContext.tsx │ ├── AppContext.tsx │ └── __tests__ │ │ └── AppContext.test.tsx ├── index.css ├── components │ ├── TerminalWindow │ │ ├── TerminalWindow.css │ │ ├── __tests__ │ │ │ ├── TerminalWindow.test.tsx │ │ │ └── __snapshots__ │ │ │ │ └── TerminalWindow.test.tsx.snap │ │ └── TerminalWindow.tsx │ ├── YouTubeAudioPlayer │ │ ├── SimpleYouTubePlayer.tsx │ │ └── YouTubeAudioPlayer.tsx │ ├── BouncyCat │ │ └── BouncyCat.tsx │ └── ControlsPanel │ │ ├── __tests__ │ │ └── ControlsPanel.test.tsx │ │ └── ControlsPanel.tsx ├── hooks │ └── useTypewriter.ts ├── data │ ├── colorThemes.ts │ └── terminalOutputs.ts ├── assets │ └── react.svg ├── utils │ └── asciiArt.ts ├── App.tsx └── pages │ └── AsciiTyper.tsx ├── public ├── og.png ├── favicon.ico ├── favicon.png ├── nyancat.gif ├── sonomaBackground.jpg └── vite.svg ├── postcss.config.js ├── vercel.json ├── tsconfig.json ├── .prettierrc ├── tailwind.config.js ├── .gitignore ├── README.md ├── vite.config.ts ├── .vercelignore ├── tsconfig.node.json ├── eslint.config.js ├── .github └── workflows │ └── ci.yml ├── tsconfig.app.json ├── index.html ├── package.json ├── .eslintrc.cjs └── planning ├── todo.md ├── spec.md └── plan.md /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/MultiTerminalCodeViz/HEAD/public/og.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/MultiTerminalCodeViz/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/MultiTerminalCodeViz/HEAD/public/favicon.png -------------------------------------------------------------------------------- /public/nyancat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/MultiTerminalCodeViz/HEAD/public/nyancat.gif -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/sonomaBackground.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/MultiTerminalCodeViz/HEAD/public/sonomaBackground.jpg -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/(.*)", 5 | "destination": "/index.html" 6 | } 7 | ] 8 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 80, 6 | "tabWidth": 2, 7 | "arrowParens": "always", 8 | "endOfLine": "lf" 9 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | 13 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # I Vibe More Than You 2 | 3 | https://www.IVibeMoreThanYou.com/ 4 | 5 | This app is for fun - PRs welcome 6 | 7 | A React app that displays multiple draggable and resizable terminal windows with animated typing effects. Each terminal shows different development scenarios with customizable themes and variable typing speeds. 8 | 9 | ## Quick Start 10 | 11 | ```bash 12 | git clone https://github.com/gkamradt/MultiTerminalCodeViz 13 | npm install 14 | npm run dev 15 | ``` -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite' 3 | import react from '@vitejs/plugin-react' 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | test: { 9 | globals: true, 10 | environment: 'jsdom', 11 | setupFiles: './src/setupTests.ts', // Optional: if you have a setup file 12 | css: true, // if you want to test CSS 13 | include: ['src/**/*.test.{ts,tsx}'], 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /.vercelignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Build outputs (handled by Vercel) 8 | dist 9 | build 10 | 11 | # Development 12 | .env.local 13 | .env.development.local 14 | .env.test.local 15 | .env.production.local 16 | 17 | # Testing 18 | coverage 19 | .nyc_output 20 | 21 | # IDE 22 | .vscode 23 | .idea 24 | *.swp 25 | *.swo 26 | 27 | # OS 28 | .DS_Store 29 | Thumbs.db 30 | 31 | # Logs 32 | logs 33 | *.log 34 | 35 | # Runtime data 36 | pids 37 | *.pid 38 | *.seed 39 | *.pid.lock -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [20.x] 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: 'npm' 26 | 27 | - name: Install dependencies 28 | run: npm ci --legacy-peer-deps 29 | 30 | - name: Lint code 31 | run: npm run lint --if-present 32 | 33 | - name: Run tests 34 | run: npm test -- --run # Add --run for Vitest to exit after tests 35 | 36 | - name: Build project 37 | run: npm run build -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | "types": ["vitest/globals"], 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | // "erasableSyntaxOnly": true, // Temporarily commented out 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedSideEffectImports": true 26 | }, 27 | "include": ["src"] 28 | } 29 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | // Mock window.innerWidth and innerHeight for the terminal layout 5 | Object.defineProperty(window, 'innerWidth', { 6 | writable: true, 7 | configurable: true, 8 | value: 1024, 9 | }); 10 | Object.defineProperty(window, 'innerHeight', { 11 | writable: true, 12 | configurable: true, 13 | value: 768, 14 | }); 15 | 16 | describe('', () => { 17 | it('renders terminal controls panel', () => { 18 | // App already includes Router, so we don't need to wrap it 19 | render(); 20 | 21 | // Check for terminal controls 22 | expect(screen.getByText('Terminal Controls')).toBeInTheDocument(); 23 | expect(screen.getByText('1 terminal')).toBeInTheDocument(); 24 | expect(screen.getByText('Arrange')).toBeInTheDocument(); 25 | expect(screen.getByText(/Theme:/)).toBeInTheDocument(); 26 | }); 27 | }); -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | I Vibe More Than You 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/contexts/ThemeContext.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-refresh/only-export-components */ 2 | import { createContext, useContext, useState } from 'react'; 3 | import type { ReactNode } from 'react'; 4 | import { terminalThemes, defaultTheme } from '../data/colorThemes'; 5 | import type { TerminalTheme, TerminalColorRole } from '../data/colorThemes'; 6 | 7 | interface ThemeContextType { 8 | currentTheme: TerminalTheme; 9 | themeName: string; 10 | setTheme: (themeName: string) => void; 11 | getThemeNames: () => string[]; 12 | getColorForRole: (role: TerminalColorRole) => string; 13 | } 14 | 15 | const ThemeContext = createContext(undefined); 16 | 17 | export function ThemeProvider({ children }: { children: ReactNode }) { 18 | const [themeName, setThemeName] = useState(defaultTheme); 19 | const currentTheme = terminalThemes[themeName]; 20 | 21 | const setTheme = (newThemeName: string) => { 22 | if (terminalThemes[newThemeName]) { 23 | setThemeName(newThemeName); 24 | } 25 | }; 26 | 27 | const getThemeNames = () => Object.keys(terminalThemes); 28 | 29 | const getColorForRole = (role: TerminalColorRole) => { 30 | return currentTheme.colors[role]; 31 | }; 32 | 33 | return ( 34 | 43 | {children} 44 | 45 | ); 46 | } 47 | 48 | export function useTheme() { 49 | const context = useContext(ThemeContext); 50 | if (context === undefined) { 51 | throw new Error('useTheme must be used within a ThemeProvider'); 52 | } 53 | return context; 54 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multiplecodeterminals", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint . --report-unused-disable-directives --max-warnings 0", 10 | "format": "prettier --write .", 11 | "test": "vitest", 12 | "test:ui": "vitest --ui", 13 | "preview": "vite preview", 14 | "vercel-build": "npm run build" 15 | }, 16 | "dependencies": { 17 | "@vercel/analytics": "^1.5.0", 18 | "html2canvas": "^1.4.1", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-draggable": "^4.4.6", 22 | "react-resizable": "^3.0.5", 23 | "react-router-dom": "^7.6.2" 24 | }, 25 | "devDependencies": { 26 | "@eslint/js": "^9.25.0", 27 | "@tailwindcss/postcss": "^4.1.8", 28 | "@testing-library/dom": "^10.4.0", 29 | "@testing-library/jest-dom": "^6.6.3", 30 | "@testing-library/react": "^16.3.0", 31 | "@testing-library/user-event": "^14.6.1", 32 | "@types/react": "^18.2.66", 33 | "@types/react-dom": "^18.2.22", 34 | "@types/react-resizable": "^3.0.8", 35 | "@types/react-router-dom": "^5.3.3", 36 | "@typescript-eslint/eslint-plugin": "^7.2.0", 37 | "@typescript-eslint/parser": "^7.2.0", 38 | "@vitejs/plugin-react": "^4.2.1", 39 | "@vitest/ui": "^3.2.0", 40 | "autoprefixer": "^10.4.19", 41 | "eslint": "^8.57.0", 42 | "eslint-config-airbnb": "^19.0.4", 43 | "eslint-config-prettier": "^10.1.5", 44 | "eslint-import-resolver-typescript": "^4.4.2", 45 | "eslint-plugin-import": "^2.29.1", 46 | "eslint-plugin-jsx-a11y": "^6.8.0", 47 | "eslint-plugin-react": "^7.34.1", 48 | "eslint-plugin-react-hooks": "^4.6.0", 49 | "eslint-plugin-react-refresh": "^0.4.6", 50 | "globals": "^16.0.0", 51 | "jsdom": "^26.1.0", 52 | "postcss": "^8.4.38", 53 | "prettier": "^3.2.5", 54 | "tailwindcss": "^3.4.3", 55 | "typescript": "^5.2.2", 56 | "typescript-eslint": "^8.30.1", 57 | "vite": "^5.2.0", 58 | "vitest": "^3.2.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | #root { 8 | height: 100vh; 9 | width: 100vw; 10 | margin: 0 !important; 11 | padding: 0 !important; 12 | overflow: hidden; 13 | box-sizing: border-box; 14 | } 15 | 16 | * { 17 | box-sizing: border-box; 18 | } 19 | 20 | :root { 21 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 22 | line-height: 1.5; 23 | font-weight: 400; 24 | 25 | color-scheme: light dark; 26 | /* Default text color and bg color will be managed by App.tsx for dark mode */ 27 | /* color: rgba(255, 255, 255, 0.87); */ 28 | /* background-color: #242424; */ 29 | 30 | font-synthesis: none; 31 | text-rendering: optimizeLegibility; 32 | -webkit-font-smoothing: antialiased; 33 | -moz-osx-font-smoothing: grayscale; 34 | } 35 | 36 | a { 37 | font-weight: 500; 38 | color: #646cff; 39 | text-decoration: inherit; 40 | } 41 | a:hover { 42 | color: #535bf2; 43 | } 44 | 45 | body { 46 | /* margin: 0; */ /* Moved to html,body,#root rule */ 47 | /* display: flex; */ /* REMOVED to allow full page content */ 48 | /* place-items: center; */ /* REMOVED */ 49 | /* min-width: 320px; */ /* REMOVED */ 50 | /* min-height: 100vh; */ /* Replaced by height: 100% */ 51 | } 52 | 53 | h1 { 54 | font-size: 3.2em; 55 | line-height: 1.1; 56 | } 57 | 58 | button { 59 | border-radius: 8px; 60 | border: 1px solid transparent; 61 | padding: 0.6em 1.2em; 62 | font-size: 1em; 63 | font-weight: 500; 64 | font-family: inherit; 65 | background-color: #1a1a1a; /* Default button color, can be overridden by Tailwind */ 66 | cursor: pointer; 67 | transition: border-color 0.25s; 68 | } 69 | button:hover { 70 | border-color: #646cff; 71 | } 72 | button:focus, 73 | button:focus-visible { 74 | outline: 4px auto -webkit-focus-ring-color; 75 | } 76 | 77 | @media (prefers-color-scheme: light) { 78 | /* :root { 79 | color: #213547; 80 | background-color: #ffffff; 81 | } */ /* App.tsx will handle theming */ 82 | a:hover { 83 | color: #747bff; 84 | } 85 | /* button { 86 | background-color: #f9f9f9; 87 | } */ /* Tailwind will handle button colors */ 88 | } 89 | -------------------------------------------------------------------------------- /src/components/TerminalWindow/TerminalWindow.css: -------------------------------------------------------------------------------- 1 | .react-resizable { 2 | position: relative; 3 | /* background-clip: padding-box; */ /* Optional: Might conflict with terminal styling */ 4 | } 5 | 6 | .react-resizable-handle { 7 | position: absolute; 8 | width: 20px; 9 | height: 20px; 10 | background-repeat: no-repeat; 11 | background-origin: content-box; 12 | box-sizing: border-box; 13 | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2IDYiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOndoaXRlMDAiIHg9IjAiIHk9IjAiIHdpZHRoPSI2cHgiIGhlaWdodD0iNnB4Ij48ZyBvcGFjaXR5PSIwLjMwMiI+PHBhdGggZD0iTSA2IDYgTCAwIDYgTCAwIDQuOCBMIDQuOCA0LjggTCA0LjggMCBMIDYgMCBMIDYgNiBaIiBmaWxsPSIjMDAwMDAwIi8+PC9nPjwvc3ZnPg=='); 14 | background-position: bottom right; 15 | padding: 0 3px 3px 0; 16 | } 17 | 18 | .react-resizable-handle-sw { 19 | bottom: 0; 20 | left: 0; 21 | cursor: sw-resize; 22 | transform: rotate(90deg); 23 | } 24 | 25 | .react-resizable-handle-se { 26 | bottom: 0; 27 | right: 0; 28 | cursor: se-resize; 29 | } 30 | 31 | .react-resizable-handle-nw { 32 | top: 0; 33 | left: 0; 34 | cursor: nw-resize; 35 | transform: rotate(180deg); 36 | } 37 | 38 | .react-resizable-handle-ne { 39 | top: 0; 40 | right: 0; 41 | cursor: ne-resize; 42 | transform: rotate(270deg); 43 | } 44 | 45 | .react-resizable-handle-w, 46 | .react-resizable-handle-e { 47 | top: 50%; 48 | margin-top: -10px; 49 | cursor: ew-resize; 50 | } 51 | 52 | .react-resizable-handle-w { 53 | left: 0; 54 | transform: rotate(135deg); 55 | } 56 | 57 | .react-resizable-handle-e { 58 | right: 0; 59 | transform: rotate(315deg); 60 | } 61 | 62 | .react-resizable-handle-n, 63 | .react-resizable-handle-s { 64 | left: 50%; 65 | margin-left: -10px; 66 | cursor: ns-resize; 67 | } 68 | 69 | .react-resizable-handle-n { 70 | top: 0; 71 | transform: rotate(225deg); 72 | } 73 | 74 | .react-resizable-handle-s { 75 | bottom: 0; 76 | transform: rotate(45deg); 77 | } 78 | 79 | /* Ensure the resizable box itself uses absolute positioning if it's inside a Draggable that will be providing x/y */ 80 | .react-resizable-box.absolute { 81 | position: absolute; 82 | } 83 | 84 | /* Hide scrollbars */ 85 | .terminal-content { 86 | /* Hide scrollbar for Chrome, Safari and Opera */ 87 | -webkit-scrollbar: none; 88 | -ms-overflow-style: none; /* IE and Edge */ 89 | scrollbar-width: none; /* Firefox */ 90 | } 91 | 92 | .terminal-content::-webkit-scrollbar { 93 | display: none; 94 | } -------------------------------------------------------------------------------- /src/hooks/useTypewriter.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | import type { TerminalLine } from '../data/terminalOutputs'; 3 | 4 | interface UseTypewriterProps { 5 | lines: TerminalLine[]; 6 | speed?: number; // ms per token (3-6 chars) 7 | enabled?: boolean; 8 | loop?: boolean; // Add loop option 9 | loopDelay?: number; // Delay before restarting 10 | } 11 | 12 | export function useTypewriter({ 13 | lines, 14 | speed = 20, // Slower since we're doing tokens now 15 | enabled = true, 16 | loop = false, 17 | loopDelay = 2000 18 | }: UseTypewriterProps) { 19 | const [displayedLines, setDisplayedLines] = useState([]); 20 | const [currentLineIndex, setCurrentLineIndex] = useState(0); 21 | const [currentCharIndex, setCurrentCharIndex] = useState(0); 22 | const timeoutRef = useRef | null>(null); 23 | 24 | // Generate random token size (3-6 characters) 25 | const getTokenSize = () => Math.floor(Math.random() * 2) + 10; // 3-6 chars 26 | 27 | useEffect(() => { 28 | if (!enabled || lines.length === 0) { 29 | return; 30 | } 31 | 32 | // Handle loop restart 33 | if (currentLineIndex >= lines.length) { 34 | if (loop) { 35 | timeoutRef.current = setTimeout(() => { 36 | setDisplayedLines([]); 37 | setCurrentLineIndex(0); 38 | setCurrentCharIndex(0); 39 | }, loopDelay); 40 | return; 41 | } else { 42 | return; 43 | } 44 | } 45 | 46 | const currentLine = lines[currentLineIndex]; 47 | const delay = currentLine.delay || 0; 48 | 49 | // Add delay before starting the line if specified 50 | if (currentCharIndex === 0 && delay > 0) { 51 | timeoutRef.current = setTimeout(() => { 52 | setCurrentCharIndex(1); 53 | }, delay); 54 | return; 55 | } 56 | 57 | // Type out the current line using tokens 58 | if (currentCharIndex < currentLine.text.length) { 59 | timeoutRef.current = setTimeout(() => { 60 | const tokenSize = getTokenSize(); 61 | const nextCharIndex = Math.min(currentCharIndex + tokenSize, currentLine.text.length); 62 | 63 | const partialLine = { 64 | ...currentLine, 65 | text: currentLine.text.slice(0, nextCharIndex) 66 | }; 67 | 68 | setDisplayedLines(prev => { 69 | const newLines = [...prev]; 70 | newLines[currentLineIndex] = partialLine; 71 | return newLines; 72 | }); 73 | 74 | setCurrentCharIndex(nextCharIndex); 75 | }, speed); 76 | } else { 77 | // Move to next line 78 | setCurrentLineIndex(prev => prev + 1); 79 | setCurrentCharIndex(0); 80 | } 81 | 82 | return () => { 83 | if (timeoutRef.current) { 84 | clearTimeout(timeoutRef.current); 85 | } 86 | }; 87 | }, [currentLineIndex, currentCharIndex, lines, speed, enabled, loop, loopDelay]); 88 | 89 | const reset = () => { 90 | setDisplayedLines([]); 91 | setCurrentLineIndex(0); 92 | setCurrentCharIndex(0); 93 | }; 94 | 95 | const isTyping = currentLineIndex < lines.length || (loop && currentLineIndex >= lines.length); 96 | 97 | return { 98 | displayedLines, 99 | isTyping, 100 | reset 101 | }; 102 | } -------------------------------------------------------------------------------- /src/data/colorThemes.ts: -------------------------------------------------------------------------------- 1 | // Semantic color roles for terminal output 2 | export type TerminalColorRole = 3 | | 'muted' // gray, secondary text 4 | | 'success' // green, success messages 5 | | 'warning' // yellow/orange, warnings 6 | | 'error' // red, errors 7 | | 'info' // blue, informational 8 | | 'accent' // purple/cyan, highlights 9 | | 'primary' // main text color 10 | | 'secondary' // slightly dimmed text 11 | | 'command'; // command/input text 12 | 13 | export interface TerminalTheme { 14 | name: string; 15 | background: string; 16 | colors: Record; 17 | } 18 | 19 | export const terminalThemes: Record = { 20 | dark: { 21 | name: 'Dark', 22 | background: 'bg-black', 23 | colors: { 24 | muted: 'text-gray-400', 25 | success: 'text-green-400', 26 | warning: 'text-yellow-400', 27 | error: 'text-red-400', 28 | info: 'text-blue-400', 29 | accent: 'text-purple-400', 30 | primary: 'text-green-200', 31 | secondary: 'text-gray-300', 32 | command: 'text-gray-500' 33 | } 34 | }, 35 | light: { 36 | name: 'Light', 37 | background: 'bg-gray-50', 38 | colors: { 39 | muted: 'text-gray-500', 40 | success: 'text-green-600', 41 | warning: 'text-orange-600', 42 | error: 'text-red-600', 43 | info: 'text-blue-600', 44 | accent: 'text-purple-600', 45 | primary: 'text-gray-800', 46 | secondary: 'text-gray-600', 47 | command: 'text-gray-700' 48 | } 49 | }, 50 | minimal: { 51 | name: 'Minimal', 52 | background: 'bg-white', 53 | colors: { 54 | muted: 'text-gray-400', 55 | success: 'text-gray-600', 56 | warning: 'text-gray-600', 57 | error: 'text-gray-700', 58 | info: 'text-gray-600', 59 | accent: 'text-gray-700', 60 | primary: 'text-gray-800', 61 | secondary: 'text-gray-600', 62 | command: 'text-gray-500' 63 | } 64 | }, 65 | retro: { 66 | name: 'Retro Green', 67 | background: 'bg-black', 68 | colors: { 69 | muted: 'text-green-600', 70 | success: 'text-green-400', 71 | warning: 'text-green-300', 72 | error: 'text-green-500', 73 | info: 'text-green-400', 74 | accent: 'text-green-300', 75 | primary: 'text-green-400', 76 | secondary: 'text-green-500', 77 | command: 'text-green-600' 78 | } 79 | }, 80 | dracula: { 81 | name: 'Dracula', 82 | background: 'bg-gray-900', 83 | colors: { 84 | muted: 'text-gray-400', 85 | success: 'text-green-400', 86 | warning: 'text-yellow-500', 87 | error: 'text-red-500', 88 | info: 'text-cyan-400', 89 | accent: 'text-purple-400', 90 | primary: 'text-pink-300', 91 | secondary: 'text-gray-300', 92 | command: 'text-purple-500' 93 | } 94 | }, 95 | solarized: { 96 | name: 'Solarized Dark', 97 | background: 'bg-[#002b36]', 98 | colors: { 99 | muted: 'text-[#839496]', 100 | success: 'text-[#859900]', 101 | warning: 'text-[#b58900]', 102 | error: 'text-[#dc322f]', 103 | info: 'text-[#268bd2]', 104 | accent: 'text-[#2aa198]', 105 | primary: 'text-[#eee8d5]', 106 | secondary: 'text-[#839496]', 107 | command: 'text-[#586e75]' 108 | } 109 | } 110 | }; 111 | 112 | export const defaultTheme = 'dark'; 113 | -------------------------------------------------------------------------------- /src/components/TerminalWindow/__tests__/TerminalWindow.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { render } from '@testing-library/react'; 3 | import { TerminalWindow } from '../TerminalWindow'; 4 | import { ThemeProvider } from '../../../contexts/ThemeContext'; 5 | 6 | describe('', () => { 7 | it('should match snapshot for static UI', () => { 8 | const { container } = render( 9 | 10 | 16 | 17 | ); 18 | // For snapshot testing with react-draggable and react-resizable, 19 | // it's often best to assert against the direct child of the ResizableBox 20 | // or a stable part of your component structure, as the wrapper elements 21 | // from these libraries can add dynamic attributes (like transform styles) 22 | // that make snapshots brittle. 23 | // Here, we target the main 'terminal-window' div directly. 24 | const terminalWindowElement = container.querySelector('.terminal-window'); 25 | expect(terminalWindowElement).toMatchSnapshot(); 26 | }); 27 | 28 | // 3.4 Clamping tests for resize (verifying props passed to ResizableBox) 29 | it('should pass correct min/max constraints to ResizableBox', () => { 30 | const { container } = render( 31 | 32 | 36 | 37 | ); 38 | 39 | // Find the ResizableBox element which has the react-resizable class 40 | const resizableBoxElement = container.querySelector('.react-resizable'); 41 | expect(resizableBoxElement).toBeInTheDocument(); 42 | 43 | // We can also check that our initial size is applied. 44 | // The ResizableBox wrapper div gets inline styles for width and height. 45 | expect(resizableBoxElement).toHaveStyle('width: 300px;'); 46 | expect(resizableBoxElement).toHaveStyle('height: 200px;'); 47 | }); 48 | 49 | it('should apply default dimensions if initialSize is not provided', () => { 50 | const { container } = render( 51 | 52 | 53 | 54 | ); 55 | const resizableBoxElement = container.querySelector('.react-resizable'); 56 | expect(resizableBoxElement).toHaveStyle('width: 650px;'); 57 | expect(resizableBoxElement).toHaveStyle('height: 450px;'); 58 | }); 59 | 60 | it('should apply partial default dimensions if initialSize is partially provided', () => { 61 | const { container: containerW } = render( 62 | 63 | 64 | 65 | ); 66 | const resizableBoxW = containerW.querySelector('.react-resizable'); 67 | expect(resizableBoxW).toHaveStyle('width: 350px;'); 68 | expect(resizableBoxW).toHaveStyle('height: 450px;'); // Default height 69 | 70 | const { container: containerH } = render( 71 | 72 | 73 | 74 | ); 75 | const resizableBoxH = containerH.querySelector('.react-resizable'); 76 | expect(resizableBoxH).toHaveStyle('width: 650px;'); // Default width 77 | expect(resizableBoxH).toHaveStyle('height: 250px;'); 78 | }); 79 | }); -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:react/recommended', 11 | 'plugin:react-hooks/recommended', 12 | 'plugin:jsx-a11y/recommended', 13 | 'airbnb', 14 | 'airbnb/hooks', 15 | 'plugin:import/typescript', 16 | 'prettier', // Make sure this is last to override other configs 17 | ], 18 | parser: '@typescript-eslint/parser', 19 | parserOptions: { 20 | ecmaFeatures: { 21 | jsx: true, 22 | }, 23 | ecmaVersion: 'latest', 24 | sourceType: 'module', 25 | project: './tsconfig.json', // Specify your tsconfig.json path 26 | }, 27 | plugins: [ 28 | '@typescript-eslint', 29 | 'react', 30 | 'react-hooks', 31 | 'jsx-a11y', 32 | 'import', 33 | 'react-refresh', // For Vite HMR 34 | ], 35 | rules: { 36 | // TypeScript specific rules 37 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 38 | '@typescript-eslint/explicit-function-return-type': 'off', 39 | '@typescript-eslint/explicit-module-boundary-types': 'off', 40 | '@typescript-eslint/no-explicit-any': 'warn', 41 | 42 | // React specific rules 43 | 'react/react-in-jsx-scope': 'off', // Not needed with React 17+ and Vite 44 | 'react/jsx-filename-extension': [1, { extensions: ['.tsx', '.jsx'] }], 45 | 'react/prop-types': 'off', // We use TypeScript for prop types 46 | 'react/jsx-props-no-spreading': 'off', 47 | 'react-hooks/rules-of-hooks': 'error', 48 | 'react-hooks/exhaustive-deps': 'warn', 49 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 50 | 51 | 52 | // Import rules 53 | 'import/extensions': [ 54 | 'error', 55 | 'ignorePackages', 56 | { 57 | js: 'never', 58 | jsx: 'never', 59 | ts: 'never', 60 | tsx: 'never', 61 | }, 62 | ], 63 | 'import/prefer-default-export': 'off', 64 | 'import/no-extraneous-dependencies': ['error', { devDependencies: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', 'vite.config.ts', '.eslintrc.cjs', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'] }], 65 | 66 | 67 | // General rules 68 | 'no-console': 'warn', 69 | 'no-unused-vars': 'off', // Handled by @typescript-eslint/no-unused-vars 70 | 'comma-dangle': ['error', 'always-multiline'], 71 | semi: ['error', 'always'], 72 | quotes: ['error', 'single'], 73 | 'object-curly-spacing': ['error', 'always'], 74 | 'arrow-parens': ['error', 'always'], 75 | 'no-shadow': 'off', // Replaced by @typescript-eslint/no-shadow 76 | '@typescript-eslint/no-shadow': ['error'], 77 | 'no-restricted-exports': 'off', 78 | 79 | // jsx-a11y improvements (can be project-specific) 80 | 'jsx-a11y/anchor-is-valid': 'off', // Often handled by routers 81 | 'jsx-a11y/label-has-associated-control': ['error', { 82 | assert: 'either', // Allows nesting or id referencing 83 | }], 84 | }, 85 | settings: { 86 | react: { 87 | version: 'detect', // Automatically detect the React version 88 | }, 89 | 'import/resolver': { 90 | typescript: { 91 | project: './tsconfig.json', 92 | }, 93 | node: { 94 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 95 | }, 96 | }, 97 | }, 98 | ignorePatterns: ['node_modules/', 'dist/', '.eslintrc.cjs', 'vite.config.ts', 'tailwind.config.js', 'postcss.config.js'], // Ignore build outputs and config files 99 | }; -------------------------------------------------------------------------------- /src/components/YouTubeAudioPlayer/SimpleYouTubePlayer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect, useCallback } from 'react'; 2 | 3 | interface YouTubeVideo { 4 | id: string; 5 | url: string; 6 | title?: string; 7 | } 8 | 9 | interface SimpleYouTubePlayerProps { 10 | videos: YouTubeVideo[]; 11 | } 12 | 13 | export const SimpleYouTubePlayer: React.FC = ({ videos }) => { 14 | const [currentVideoIndex, setCurrentVideoIndex] = useState(0); 15 | const [isPlaying, setIsPlaying] = useState(false); 16 | const iframeRef = useRef(null); 17 | 18 | const currentVideo = videos[currentVideoIndex]; 19 | 20 | const getEmbedUrl = useCallback((videoUrl: string) => { 21 | const videoId = videoUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&\n?#]+)/)?.[1]; 22 | return `https://www.youtube.com/embed/${videoId}?enablejsapi=1&autoplay=0&controls=0&showinfo=0&rel=0&modestbranding=1`; 23 | }, []); 24 | 25 | const togglePlayPause = () => { 26 | if (iframeRef.current && iframeRef.current.contentWindow) { 27 | const command = isPlaying ? 'pauseVideo' : 'playVideo'; 28 | iframeRef.current.contentWindow.postMessage( 29 | `{"event":"command","func":"${command}","args":""}`, 30 | '*' 31 | ); 32 | setIsPlaying(!isPlaying); 33 | } 34 | }; 35 | 36 | useEffect(() => { 37 | if (iframeRef.current) { 38 | iframeRef.current.src = getEmbedUrl(currentVideo.url); 39 | } 40 | 41 | // Auto-advance to next video when current one ends 42 | const handleMessage = (event: MessageEvent) => { 43 | if (event.origin !== 'https://www.youtube.com') return; 44 | 45 | try { 46 | const data = JSON.parse(event.data); 47 | if (data.event === 'video-progress' && data.info === 0) { // Video ended 48 | const nextIndex = (currentVideoIndex + 1) % videos.length; 49 | setCurrentVideoIndex(nextIndex); 50 | setIsPlaying(false); 51 | } 52 | } catch { 53 | // Ignore invalid JSON 54 | } 55 | }; 56 | 57 | window.addEventListener('message', handleMessage); 58 | return () => window.removeEventListener('message', handleMessage); 59 | }, [currentVideoIndex, currentVideo.url, getEmbedUrl, videos.length]); 60 | 61 | if (!videos.length) { 62 | return null; 63 | } 64 | 65 | return ( 66 | <> 67 | {/* Hidden iframe for audio playback */} 68 |