I'm thrilled to share the latest milestone in my journey to create the ultimate desktop environment portfolio! I've just finished implementing a fully functional terminal with draggable and resizable windows, all powered by custom code.
This feature allows users to interact with the simulated operating system in a familiar and intuitive way. I've even included some dummy commands to give you a taste of what's possible.
Building a realistic desktop environment for a portfolio website is no small feat, and adding a functional terminal takes it to a whole new level. This terminal isn't just a static element; it's a dynamic, interactive component that allows users to explore and engage with the simulated operating system.
Getting Started: Setting Up Your Next.js Project
Before diving into the terminal code, let's walk through the initial setup. We'll be using Next.js for its robust features and excellent developer experience.
-
Create a New Next.js App:
Open your terminal and run the following command to create a new Next.js project:
npx create-next-app my-terminal-app cd my-terminal-app
This will set up a basic Next.js project structure for you.
-
Install Required Packages:
We'll need a package to handle drag-and-drop, and type definitions. Install them using npm or yarn:
npm install react-rnd npm install -D @types/node @types/react @types/react-dom typescript
- react-rnd: This package provides powerful and flexible drag-and-resize functionality. It's perfect for creating resizable and movable windows, which is essential for our terminal.
@types/*: These packages provide TypeScript type definitions for Node.js, React, and React DOM. Using TypeScript improves code quality and maintainability by adding static typing.
typescript: needed for typescript support.
Important Note Regarding next/dynamic
:
The next/dynamic
import is a built-in feature of Next.js, not a separate package. It allows us to dynamically import components, which is crucial for client-side rendering. Since our terminal component relies on browser-specific APIs, we need to ensure it's not rendered on the server.
Diving into the Code: React-Powered Terminal Magic
Let's take a peek at the React JS code that powers this terminal. We're leveraging next.js
for our application, react-rnd
for the drag-and-resize functionality, and a custom TerminalComponent
to handle the terminal logic.
pages.tsx
(Main Page):
"use client";
import dynamic from "next/dynamic";
import { Rnd } from "react-rnd";
const TerminalComponent = dynamic(() => import("./components/Terminal"), {
ssr: false, // Important: Disable server-side rendering
});
export default function Home() {
return (
<div className="w-full h-screen">
<Rnd
default={{
x: 20,
y: 20,
width: 600,
height: 400,
}}
minWidth={200}
minHeight={150}
style={{
border: "1px solid #ddd",
background: "#f0f0f0",
display: "flex",
flexDirection: "column",
}}
>
<TerminalComponent />
</Rnd>
</div>
);
}
Here, we use react-rnd
to make the terminal window draggable and resizable. The dynamic
import ensures that the TerminalComponent
is only loaded on the client-side, which is crucial since it relies on browser-specific APIs.
index.tsx
(Terminal Component):
// components/Terminal.tsx
"use client";
import React, { useEffect, useRef, useState } from "react";
import type { AvailableCommands, NestedCommands } from "../data/command";
const BashTerminal: React.FC = () => {
// 1. Importing Dependencies and Defining Types:
// (Import statements already present)
// 2. Initializing State Variables:
const [cmd, setCmd] = useState<string>("");
const [output, setOutput] = useState<string>("");
const [history, setHistory] = useState<string[]>([]);
const terminalRef = useRef<HTMLDivElement>(null);
const [nestedMode, setNestedMode] = useState<keyof NestedCommands | null>(null);
const hostname = "terminal";
const username = "terminal";
const [directory, setDirectory] = useState<string>("~");
// 3. Utility Functions:
const print = (text: string, currentOutput: string): string => {
return currentOutput + text;
};
const command = (outputText: string, currentOutput: string): string => {
return print(`${outputText}\n${username}@${hostname} ${directory} $ `, currentOutput);
};
const empty = (currentOutput = ""): string => {
return print(`${username}@${hostname} ${directory} $ `, currentOutput);
};
const setup = (): string => {
return empty();
};
const cd = (dir: string, param: string | undefined): string => {
if (param === undefined) {
return "~";
}
if (param.charAt(0) === "/") {
return param;
}
return `${dir}/${param}`;
};
// 4. Command Definitions:
const availableCommands: AvailableCommands = {
pwd: () => directory,
cd: (tokens) => {
setDirectory(cd(directory, tokens[1]));
return null;
},
echo: (tokens) => tokens.slice(1).join(" "),
clear: () => ({ clear: true }),
history: () => history.join("\n"),
help: () => "Available commands: clear, echo, cd, pwd, history, help, mycommand",
mycommand: () => {
setNestedMode("mycommand");
return "Entered mycommand mode. Type 'list', 'info', or 'exit'.";
},
};
const nestedCommands: NestedCommands = {
mycommand: {
list: () => "Item 1, Item 2, Item 3",
info: () => "This is info within mycommand.",
exit: () => {
setNestedMode(null);
return `\n${username}@${hostname} ${directory} $ `;
},
},
};
// 5. Command Execution:
const run = async (cmd: string): Promise<string | { clear: boolean } | null> => {
const tokens = cmd.split(" ");
const commandName = tokens[0];
if (nestedMode) {
if (nestedCommands[nestedMode] && commandName in nestedCommands[nestedMode]) {
const nestedModeObject = nestedCommands[nestedMode];
if (typeof nestedModeObject === "object" && nestedModeObject !== null && commandName in nestedModeObject) {
return nestedModeObject[commandName as keyof typeof nestedModeObject]();
}
}
return `Command not found in ${nestedMode}: ${commandName}`;
}
if (commandName in availableCommands) {
const result = availableCommands[commandName as keyof typeof availableCommands](tokens);
if (result instanceof Promise) {
return await result;
}
return result;
}
return commandName ? `Command not found: ${commandName}` : "";
};
// 6. Effect Hooks:
useEffect(() => {
setOutput(setup());
terminalRef.current?.focus();
}, []);
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.scrollTo({
top: terminalRef.current.scrollHeight,
behavior: "smooth",
});
}
}, [output]);
// 7. Event Handling:
const handleKeyDown = async (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.ctrlKey && e.shiftKey && e.key === "V") {
e.preventDefault();
navigator.clipboard.readText().then(text => setCmd(prev => prev + text)).catch(err => {
console.error("Clipboard access failed:", err);
alert("Clipboard access denied. Please check your browser permissions.");
});
return;
}
if (e.key === "Backspace") {
e.preventDefault();
setCmd(prev => prev.slice(0, -1));
} else if (e.key === "Enter") {
e.preventDefault();
const cmdToRun = cmd.trim();
if (cmdToRun) {
setHistory(prev => [...prev, cmdToRun]);
const result = await run(cmdToRun.toLowerCase());
setOutput(prev => {
const commandLine = `${username}@${hostname} ${directory} $ ${cmdToRun}`;
let resultOutput: string | { clear: boolean } | null = "";
if (result === null) resultOutput = `${username}@${hostname} ${directory} $ `;
else if (typeof result === "object" && result.clear) return empty();
else resultOutput = typeof result === "string" && result.includes("\n") ? result : `\n${command(typeof result === "string" ? result : "", "")}`;
const lastPromptIndex = prev.lastIndexOf(`${username}@${hostname} ${directory} $ `);
const cleanedPrev = lastPromptIndex !== -1 ? prev.substring(0, lastPromptIndex) : prev;
return cleanedPrev + commandLine + (typeof resultOutput === "string" ? resultOutput : "");
});
} else setOutput(prev => empty(prev));
setCmd("");
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) setCmd(prev => prev + e.key);
};
// 8. Rendering the Terminal:
return (
<div className="flex flex-col w-full h-full p-4 bg-gray-900 text-green-400 font-mono border border-gray-700 shadow-lg overflow-hidden" tabIndex={0} onKeyDown={handleKeyDown} ref={terminalRef}>
<div className="flex items-center bg-gray-800 px-4 py-2 border-b border-gray-700">
<div className="flex space-x-2 mr-4">
<div className="w-3 h-3 rounded-full bg-red-500" />
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<div className="w-3 h-3 rounded-full bg-green-500" />
</div>
<div className="text-sm text-gray-400">bash</div>
</div>
<pre className="flex-1 p-4 overflow-y-auto text-sm leading-relaxed whitespace-pre-wrap break-words">
{output}
<span className="inline-flex items-center">
{cmd}
<span className="ml-1 w-2 h-5 bg-green-400 animate-pulse">|</span>
</span>
</pre>
</div>
);
};
export default BashTerminal;
commands.tsx
(Command Types):
interface AvailableCommands {
pwd: () => string;
cd: (tokens: string[]) => string | null;
echo: (tokens: string[]) => string;
clear: () => { clear: boolean };
history: () => string;
help: () => string;
mycommand: () => string;
}
interface NestedCommands {
mycommand: {
list: () => string;
info: () => string;
exit: () => string;
};
}
export type { AvailableCommands, NestedCommands };
This file defines the types for our commands, ensuring type safety and code clarity.
Technical Highlights:
- Client-Side Rendering with
next/dynamic
: As mentioned earlier,next/dynamic
withssr: false
is crucial for our terminal component. Browser-specific APIs like DOM manipulation are only available on the client side. - Drag-and-Resize with
react-rnd
:react-rnd
simplifies the process of creating draggable and resizable elements. Its intuitive API and extensive customization options make it a great choice for our terminal windows. - State Management with
useState
: We useuseState
to manage the terminal's state, including command input, output, and history. React's state management ensures that the terminal updates correctly in response to user interactions. - DOM Manipulation with
useRef
:useRef
allows us to access the terminal's DOM element for scrolling and focus management. This is essential for providing a smooth and responsive user experience. - Event Handling with
onKeyDown
: We useonKeyDown
to capture keyboard input, enabling users to type commands and interact with the terminal. - TypeScript for Type Safety: By using TypeScript and type definitions, we ensure that our code is type-safe, reducing the risk of runtime errors and improving code maintainability.
- Clipboard integration: added ctrl+shift+v for paste from clipboard.
Why These Packages?
-
react-rnd
: I chosereact-rnd
because of its ease of use and flexibility. It provides a simple and efficient way to add drag-and-resize functionality to React components, which is essential for creating a realistic desktop environment. - TypeScript: I opted for TypeScript to improve code quality and maintainability. Its static typing helps catch errors early and makes the codebase easier to understand and refactor.
Looking Ahead: What's Next?
Stay tuned for more updates as I continue to build the "GOAT" portfolio website! What features are you most excited to see next? I'm planning to add more interactive elements, enhance the simulated operating system, and refine the overall user experience.
Some potential features I'm considering include:
- Implementing a file system and file explorer.
- Adding more complex commands and utilities.
- Creating a more visually appealing desktop environment.
- Adding more nested commands.
- Adding more commands to the available commands.
Looking Ahead: What's Next?
Stay tuned for more updates as I continue to build the 'GOAT' portfolio website! What features are you most excited to see next?
Top comments (0)