feat: makingsoftware.com pixel-perfect portal transformation
24
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
16
README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
25
SECURITY.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Security & Anti-DDoS Guide
|
||||
|
||||
To fully protect your Synology NAS from DDoS attacks and hide your home IP address, you must use a reverse proxy service like **Cloudflare**. Frontend code alone cannot hide your server's IP.
|
||||
|
||||
## Step 1: Create a Cloudflare Account
|
||||
1. Go to [Cloudflare.com](https://www.cloudflare.com/) and sign up.
|
||||
2. Click **Add a Site** and enter your domain (e.g., `khoavo.i234.me`).
|
||||
|
||||
## Step 2: Update DNS Records
|
||||
1. Cloudflare will scan your existing DNS records.
|
||||
2. Ensure your `A` records (pointing to your home IP) are set to **Proxied** (Orange Cloud icon).
|
||||
* **Orange Cloud**: Traffic goes through Cloudflare -> Your NAS. (IP Hidden, DDoS Protected)
|
||||
* **Grey Cloud**: Traffic goes directly to your NAS. (IP Exposed, No Protection)
|
||||
|
||||
## Step 3: Configure SSL/TLS
|
||||
1. Go to the **SSL/TLS** tab in Cloudflare.
|
||||
2. Set the mode to **Full (Strict)** if your NAS has a valid certificate, or **Flexible** if it doesn't.
|
||||
|
||||
## Step 4: Firewall Rules (Optional but Recommended)
|
||||
1. Go to **Security > WAF**.
|
||||
2. Create a rule to **Block** traffic from countries you don't expect visitors from.
|
||||
3. Enable **Bot Fight Mode** to block automated attacks.
|
||||
|
||||
## Why this is necessary?
|
||||
When you host a website on your NAS, your domain `khoavo.i234.me` translates directly to your home IP address. Anyone on the internet can see this IP. By using Cloudflare as a "middleman", visitors only see Cloudflare's IP, keeping your home network safe.
|
||||
29
eslint.config.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
18
index.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VNDANGKHOA</title>
|
||||
<meta http-equiv="Referrer-Policy" content="no-referrer" />
|
||||
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
3771
package-lock.json
generated
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "vndangkhoa",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.23.24",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
1
public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
122
src/App.jsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import React, { useState } from 'react';
|
||||
import { links, groups } from './data/links';
|
||||
import Header from './components/Header';
|
||||
import DotDivider from './components/DotDivider';
|
||||
import Section from './components/Section';
|
||||
import PortalCard from './components/PortalCard';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
// Relative assets (ported from generated artifacts)
|
||||
import featuredPrimary from './assets/illustrations/featured_blueprint_primary_1776568228720.png';
|
||||
|
||||
function App() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Enhanced links with categories and colors for the MakingSoftware look
|
||||
const enhancedLinks = links.map(link => {
|
||||
let color = '#3147ba'; // Primary Blue
|
||||
|
||||
if (link.group === 'entertainment') {
|
||||
color = '#ec4899';
|
||||
} else if (link.group === 'dev') {
|
||||
color = '#10b981';
|
||||
} else if (link.group === 'rm8pfix-vn') {
|
||||
color = '#f59e0b';
|
||||
}
|
||||
|
||||
return {
|
||||
...link,
|
||||
color
|
||||
};
|
||||
});
|
||||
|
||||
const filteredLinks = enhancedLinks.filter(link =>
|
||||
link.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
link.subtitle.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Header />
|
||||
|
||||
<main>
|
||||
{groups.map((group, groupIndex) => {
|
||||
const groupLinks = filteredLinks
|
||||
.filter(link => link.group === group.id)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
if (groupLinks.length === 0) return null;
|
||||
|
||||
const isPrimary = group.id === 'primary';
|
||||
|
||||
return (
|
||||
<React.Fragment key={group.id}>
|
||||
<Section
|
||||
title={group.title}
|
||||
description={group.description}
|
||||
index={groupIndex}
|
||||
isFeatured={isPrimary && !searchQuery} // Show as featured only if not searching
|
||||
featuredImage={isPrimary ? featuredPrimary : null}
|
||||
>
|
||||
{groupLinks.map((link, linkIndex) => (
|
||||
<PortalCard
|
||||
key={link.id}
|
||||
item={link}
|
||||
index={linkIndex}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
{/* Position Search Bar immediately after the Primary Featured section */}
|
||||
{isPrimary && (
|
||||
<div className="ms-container pt-0 pb-6 md:pb-8">
|
||||
<div className="mb-8"><DotDivider /></div>
|
||||
<div className="relative group max-w-md">
|
||||
<div className="absolute -left-4 top-1/2 -translate-y-1/2 font-mono text-[10px] text-blue-600 font-bold vertical-text hidden md:block">
|
||||
SEARCH
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-b-2 border-black pb-2 focus-within:border-blue-600 transition-colors">
|
||||
<Search className="text-gray-300 group-focus-within:text-blue-600 shrink-0" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="FIND SYSTEM ACCESS..."
|
||||
className="w-full bg-transparent font-pixel text-[13px] md:text-sm focus:outline-none placeholder:text-gray-200"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupIndex < groups.length - 1 && !isPrimary && <div className="mt-4"><DotDivider /></div>}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{searchQuery && filteredLinks.length === 0 && (
|
||||
<div className="text-center py-12 px-4">
|
||||
<p className="font-pixel text-[10px] md:text-xs text-gray-300 uppercase tracking-widest">[ ERROR: NO ACCESS MATCHED ]</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="ms-container border-t border-gray-100 mt-8 md:mt-12 pb-12 flex flex-col md:flex-row justify-between items-center md:items-end gap-8">
|
||||
<div className="flex items-center gap-2 w-full md:w-auto">
|
||||
<h2 className="font-pixel text-blue-600 text-xs md:text-sm">VNDANGKHOA_PORTAL</h2>
|
||||
</div>
|
||||
|
||||
<div className="text-center md:text-right w-full md:w-auto">
|
||||
<p className="font-serif text-[10px] md:text-[11px] font-bold text-gray-900 italic">
|
||||
"Software engineering is the most important trade of the 21st century."
|
||||
</p>
|
||||
<p className="font-mono text-[9px] text-gray-400 mt-2 tracking-widest">
|
||||
© {new Date().getFullYear()} REPRODUCED PIXEL PERFECT
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
After Width: | Height: | Size: 776 KiB |
BIN
src/assets/illustrations/media__1776567415337.png
Normal file
|
After Width: | Height: | Size: 492 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 477 KiB |
1
src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
60
src/components/ArticleCard.jsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
|
||||
const ArticleCard = ({ item, index }) => {
|
||||
const { title, subtitle, url, category, image } = item;
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-100px" });
|
||||
|
||||
// Default color if none provided
|
||||
const accentColor = item.color || '#3b82f6';
|
||||
|
||||
return (
|
||||
<motion.a
|
||||
ref={ref}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="ms-card group block overflow-hidden bg-white"
|
||||
>
|
||||
<div className="aspect-[16/9] overflow-hidden bg-gray-50 mb-6 rounded-2xl relative">
|
||||
<motion.img
|
||||
src={image || `https://api.placeholder.com/800/450`}
|
||||
alt={title}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ duration: 0.8, ease: [0.33, 1, 0.68, 1] }}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 opacity-10 group-hover:opacity-20 transition-opacity"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-1">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span
|
||||
className="mono text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 rounded border"
|
||||
style={{ color: accentColor, borderColor: `${accentColor}33`, backgroundColor: `${accentColor}11` }}
|
||||
>
|
||||
{category || 'Software'}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 font-medium">8 min read</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-bold tracking-tight mb-3 group-hover:text-blue-600 transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-500 text-sm leading-relaxed line-clamp-2">
|
||||
{subtitle || 'Deep dive into the underlying principles and implementation details of this concept.'}
|
||||
</p>
|
||||
</div>
|
||||
</motion.a>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleCard;
|
||||
9
src/components/DotDivider.jsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
const DotDivider = () => {
|
||||
return (
|
||||
<div className="dot-divider" aria-hidden="true" />
|
||||
);
|
||||
};
|
||||
|
||||
export default DotDivider;
|
||||
24
src/components/Header.jsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
|
||||
const Header = () => {
|
||||
return (
|
||||
<header className="ms-container pt-8 md:pt-12 pb-4 md:pb-8 flex flex-col md:flex-row justify-between items-start md:items-baseline gap-6 md:gap-4">
|
||||
<div className="flex flex-col w-full md:w-auto">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-6xl text-blue-600 font-pixel tracking-tighter leading-none">
|
||||
VNDANGKHOA<span className="text-gray-200">_</span>PORTAL
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col text-left md:text-right md:max-w-xs transition-opacity hover:opacity-100 opacity-80">
|
||||
<p className="font-serif text-[12px] md:text-[13px] leading-relaxed italic text-gray-800">
|
||||
A reference manual for people who design and build software.
|
||||
</p>
|
||||
<p className="font-serif text-[11px] md:text-[12px] font-bold mt-1 text-black uppercase tracking-wider">
|
||||
Managed by Khoa Vo.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
27
src/components/Hero.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const Hero = () => {
|
||||
return (
|
||||
<section className="ms-section pt-24 pb-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<span className="mono text-[10px] uppercase font-bold tracking-[0.3em] text-blue-600 mb-2 block">
|
||||
Central Access Portal
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-5xl font-black tracking-tighter mb-4">
|
||||
VNDANGKHOA<span className="text-gray-300">.</span>PORTAL
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 max-w-xl leading-relaxed">
|
||||
Quick entry point for all internal systems, media platforms, and utility tools.
|
||||
Powered by the <span className="ms-highlight font-medium text-black">Making Software</span> design system.
|
||||
</p>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hero;
|
||||
35
src/components/LinkItem.jsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
const LinkItem = ({ link, index }) => {
|
||||
const { title, subtitle, url, icon: Icon } = link;
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-50px" });
|
||||
|
||||
return (
|
||||
<motion.a
|
||||
ref={ref}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
className="flex items-center justify-between py-2 sm:py-3 px-3 sm:px-4 box-thin invert-hover cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<div className="p-0.5">
|
||||
<Icon size={16} strokeWidth={2} className="sm:size-[20px]" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-sm sm:text-lg">{title}</span>
|
||||
<span className="text-xs sm:text-sm text-gray-500 ml-1 sm:ml-2 group-hover:text-white">{subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="sm:size-[18px] group-hover:text-white" />
|
||||
</motion.a>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkItem;
|
||||
47
src/components/PortalCard.jsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const PortalCard = ({ item, index }) => {
|
||||
const { title, subtitle, url, color } = item;
|
||||
|
||||
return (
|
||||
<motion.a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: index * 0.05 }}
|
||||
className="tech-label group block relative"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[10px] text-gray-300 font-bold uppercase tracking-tight">
|
||||
[ PORT_ACCESS ]
|
||||
</span>
|
||||
<div
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="font-mono text-[13px] font-extrabold text-black flex items-center gap-1 group-hover:text-blue-600 transition-colors">
|
||||
<span className="opacity-40 group-hover:opacity-100 transition-opacity">→</span> {title.toUpperCase()}
|
||||
</h3>
|
||||
|
||||
<p className="font-serif text-[11px] italic text-gray-400 leading-tight">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Technical bracket decorations */}
|
||||
<div className="absolute top-0 right-0 p-1 opacity-20 group-hover:opacity-100 transition-all">
|
||||
<div className="font-mono text-[8px] vertical-text">
|
||||
EST. 2026
|
||||
</div>
|
||||
</div>
|
||||
</motion.a>
|
||||
);
|
||||
};
|
||||
|
||||
export default PortalCard;
|
||||
62
src/components/Section.jsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
|
||||
const Section = ({ title, description, children, index, isFeatured, featuredImage }) => {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-100px" });
|
||||
|
||||
// Format index as 001, 002, etc.
|
||||
const figNumber = String(index + 1).padStart(3, '0');
|
||||
|
||||
return (
|
||||
<section className={`ms-container relative py-4 md:py-8 flex flex-col ${isFeatured ? 'md:flex-col lg:flex-row' : 'md:flex-row'} gap-6 md:gap-12`}>
|
||||
{/* Sidebar Illustration Info */}
|
||||
<div className={`${isFeatured ? 'lg:w-1/2' : 'md:w-1/3'} flex group`}>
|
||||
<div className="relative w-full">
|
||||
{/* Vertical Label - Desktop Only */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="hidden md:block vertical-text absolute -left-8 top-0 text-[10px] font-mono text-gray-300 tracking-widest uppercase font-bold"
|
||||
>
|
||||
FIG. {figNumber}
|
||||
</motion.div>
|
||||
|
||||
<div className="md:pr-4">
|
||||
<h2 className={`${isFeatured ? 'text-2xl md:text-3xl' : 'text-xl md:text-sm'} font-pixel text-blue-600 mb-6 md:mb-8 tracking-wider uppercase`}>
|
||||
{isFeatured ? `FEATURED: ${title}` : title}
|
||||
</h2>
|
||||
<p className={`${isFeatured ? 'text-[18px] md:text-[20px] leading-relaxed' : 'text-[15px] md:text-[14px] leading-relaxed'} font-serif text-gray-800 drop-cap opacity-95 group-hover:opacity-100 transition-opacity`}>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{isFeatured && featuredImage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={isInView ? { opacity: 1, scale: 1 } : {}}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="mt-12 border border-gray-100 p-2 bg-white shadow-2xl shadow-blue-900/5"
|
||||
>
|
||||
<img src={featuredImage} alt="Featured Technical Illustration" className="w-full h-auto" />
|
||||
<div className="mt-4 flex justify-between items-center font-mono text-[9px] text-gray-400 uppercase tracking-widest px-2">
|
||||
<span>SCALE 1:15</span>
|
||||
<span>EXPLODED_VIEW_DIG.V1</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Grid Content */}
|
||||
<div className={`${isFeatured ? 'lg:w-1/2' : 'md:w-2/3'}`}>
|
||||
<div className={`grid grid-cols-1 ${isFeatured ? 'sm:grid-cols-1 md:grid-cols-2' : 'sm:grid-cols-2'} gap-4`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
98
src/components/ServiceCard.jsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import React from 'react';
|
||||
import { motion, useMotionValue, useTransform } from 'framer-motion';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
const ServiceCard = ({ link, index, size = "default" }) => {
|
||||
const { title, subtitle, url, icon: Icon, color, shadow } = link;
|
||||
|
||||
const sizeClasses = size === "large"
|
||||
? "w-40 sm:w-48 md:w-56 p-4 sm:p-6 md:p-8"
|
||||
: "w-28 sm:w-32 md:w-36 p-3 sm:p-4 md:p-5";
|
||||
const iconSize = size === "large"
|
||||
? "w-8 h-8 sm:w-9 md:w-10"
|
||||
: "w-6 h-6 sm:w-7 md:w-8";
|
||||
const titleSize = size === "large"
|
||||
? "text-lg sm:text-xl md:text-2xl"
|
||||
: "text-sm sm:text-base md:text-lg";
|
||||
|
||||
// Motion values for tilt effect
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
|
||||
// Smooth rotation
|
||||
const rotateX = useTransform(y, [-100, 100], [15, -15]);
|
||||
const rotateY = useTransform(x, [-100, 100], [-15, 15]);
|
||||
|
||||
function handleMouseMove(event) {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const mouseY = event.clientY - rect.top;
|
||||
const xPct = mouseX / width - 0.5;
|
||||
const yPct = mouseY / height - 0.5;
|
||||
x.set(xPct * 200); // Amplify movement
|
||||
y.set(yPct * 200);
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
x.set(0);
|
||||
y.set(0);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
style={{
|
||||
perspective: 1000,
|
||||
}}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<motion.a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{
|
||||
rotateX,
|
||||
rotateY,
|
||||
transformStyle: "preserve-3d",
|
||||
}}
|
||||
className={`relative group block ${sizeClasses} rounded-2xl bg-white/50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800 backdrop-blur-xl overflow-hidden hover:border-slate-400 dark:hover:border-slate-600 transition-colors duration-300 ${shadow} hover:shadow-2xl`}
|
||||
>
|
||||
{/* Glare Effect */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none z-20"
|
||||
style={{
|
||||
background: 'radial-gradient(circle at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(255,255,255,0.1) 0%, transparent 80%)'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`absolute inset-0 opacity-0 group-hover:opacity-20 transition-opacity duration-500 bg-gradient-to-br ${color}`}
|
||||
style={{ transform: "translateZ(-20px)" }}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex items-start justify-between" style={{ transform: "translateZ(20px)" }}>
|
||||
<div className={`p-2 sm:p-3 rounded-lg sm:rounded-xl bg-slate-100/50 dark:bg-slate-800/50 text-slate-900 dark:text-white group-hover:scale-110 transition-transform duration-300`}>
|
||||
<Icon className={`${iconSize} text-slate-900 dark:text-white`} />
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4 sm:w-5 sm:h-5 text-slate-400 dark:text-slate-500 group-hover:text-slate-900 dark:group-hover:text-white transition-colors" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-2 sm:mt-3 md:mt-4" style={{ transform: "translateZ(30px)" }}>
|
||||
<h3 className={`${titleSize} font-bold text-slate-900 dark:text-white group-hover:text-transparent group-hover:bg-clip-text group-hover:bg-gradient-to-r group-hover:from-slate-900 group-hover:to-slate-600 dark:group-hover:from-white dark:group-hover:to-slate-300 transition-all`}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs sm:text-sm text-slate-500 dark:text-slate-400 group-hover:text-slate-700 dark:group-hover:text-slate-300 transition-colors">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</motion.a>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceCard;
|
||||
43
src/components/Spotlight.jsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { motion, useSpring, useMotionValue } from 'framer-motion';
|
||||
|
||||
const Spotlight = () => {
|
||||
const mouseX = useMotionValue(0);
|
||||
const mouseY = useMotionValue(0);
|
||||
|
||||
// Smooth out the mouse movement
|
||||
const springConfig = { damping: 25, stiffness: 150 };
|
||||
const x = useSpring(mouseX, springConfig);
|
||||
const y = useSpring(mouseY, springConfig);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e) => {
|
||||
// Center the spotlight on the cursor
|
||||
mouseX.set(e.clientX - 250);
|
||||
mouseY.set(e.clientY - 250);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
};
|
||||
}, [mouseX, mouseY]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="pointer-events-none fixed inset-0 z-0 overflow-hidden"
|
||||
style={{ opacity: 0.6 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute w-[500px] h-[500px] rounded-full bg-gradient-to-r from-indigo-500/30 to-purple-500/30 blur-3xl"
|
||||
style={{
|
||||
x,
|
||||
y,
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Spotlight;
|
||||
148
src/data/links.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { Youtube, Video, FileText, Image, Terminal, Film, Music, Briefcase, Wrench, Monitor, Cpu, HardDrive } from 'lucide-react';
|
||||
|
||||
export const links = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Portfolio",
|
||||
subtitle: "My Work",
|
||||
url: "https://portfolio.khoavo.myds.me",
|
||||
icon: Briefcase,
|
||||
group: "primary",
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "CV",
|
||||
subtitle: "Resume",
|
||||
url: "https://cv.khoavo.myds.me",
|
||||
icon: FileText,
|
||||
group: "primary",
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "YouTube",
|
||||
subtitle: "No Ads",
|
||||
url: "https://ut.khoavo.myds.me",
|
||||
icon: Youtube,
|
||||
group: "entertainment",
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "TikTok",
|
||||
subtitle: "No Ads",
|
||||
url: "https://tt.khoavo.myds.me",
|
||||
icon: Video,
|
||||
group: "entertainment",
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Music",
|
||||
subtitle: "Streaming",
|
||||
url: "https://music.khoavo.myds.me",
|
||||
icon: Music,
|
||||
group: "entertainment",
|
||||
order: 3
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Spotify",
|
||||
subtitle: "Streaming",
|
||||
url: "https://sp.khoavo.myds.me",
|
||||
icon: Music,
|
||||
group: "entertainment",
|
||||
order: 4
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "Netflix",
|
||||
subtitle: "Streaming",
|
||||
url: "https://nf.khoavo.myds.me",
|
||||
icon: Film,
|
||||
group: "entertainment",
|
||||
order: 5
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: "RM8PFix",
|
||||
subtitle: "Fixes & Tools",
|
||||
url: "https://rm8pfix.khoavo.myds.me",
|
||||
icon: Wrench,
|
||||
group: "rm8pfix-vn",
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: "Portal",
|
||||
subtitle: "Save",
|
||||
url: "https://save.khoavo.myds.me",
|
||||
icon: HardDrive,
|
||||
group: "rm8pfix-vn",
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: "Free",
|
||||
subtitle: "Free",
|
||||
url: "https://free.khoavo.myds.me",
|
||||
icon: Wrench,
|
||||
group: "rm8pfix-vn",
|
||||
order: 3
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
title: "PDF",
|
||||
subtitle: "Tools",
|
||||
url: "https://pdf.khoavo.myds.me",
|
||||
icon: FileText,
|
||||
group: "dev",
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: "JPG",
|
||||
subtitle: "Tools",
|
||||
url: "https://jpg.khoavo.myds.me",
|
||||
icon: Image,
|
||||
group: "dev",
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
title: "IT Utilities",
|
||||
subtitle: "Dev Tools",
|
||||
url: "https://it.khoavo.myds.me",
|
||||
icon: Terminal,
|
||||
group: "dev",
|
||||
order: 3
|
||||
},
|
||||
];
|
||||
|
||||
export const groups = [
|
||||
{
|
||||
id: 'primary',
|
||||
title: 'PRIMARY',
|
||||
icon: Briefcase,
|
||||
description: "Explore the underlying principles of the primary ecosystem. Each component is designed for maximum efficiency and conceptual clarity in your daily workflow."
|
||||
},
|
||||
{
|
||||
id: 'entertainment',
|
||||
title: 'ENTERTAINMENT',
|
||||
icon: Monitor,
|
||||
description: "Digital consumption and leisure systems. A curated selection of platforms for high-fidelity media streaming and interactive entertainment."
|
||||
},
|
||||
{
|
||||
id: 'rm8pfix-vn',
|
||||
title: 'RM8PFIX-VN',
|
||||
icon: Wrench,
|
||||
description: "Specialized system utilities and maintenance protocols for local network environments. Ensuring stability and performance through automated tooling."
|
||||
},
|
||||
{
|
||||
id: 'dev',
|
||||
title: 'DEV TOOLS',
|
||||
icon: Cpu,
|
||||
description: "A comprehensive toolkit for the modern developer. From binary manipulation to layout verification, these utilities accelerate the build process."
|
||||
},
|
||||
];
|
||||
96
src/index.css
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&family=PT+Serif:ital,wght@0,400;0,700;1,400;1,700&family=IBM+Plex+Mono:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #6b7280;
|
||||
--accent: #3147ba;
|
||||
--accent-soft: rgba(49, 71, 186, 0.05);
|
||||
--border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-white text-black antialiased font-serif;
|
||||
background-image:
|
||||
linear-gradient(var(--accent-soft) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--accent-soft) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
h1, h2, h3, .font-pixel {
|
||||
font-family: 'Silkscreen', cursive;
|
||||
}
|
||||
|
||||
p, .font-serif {
|
||||
font-family: 'PT Serif', serif;
|
||||
}
|
||||
|
||||
code, pre, .font-mono {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
/* Technical Layout Utilities */
|
||||
.ms-container {
|
||||
@apply max-w-[1100px] mx-auto px-6 md:px-12 py-8 md:py-12;
|
||||
}
|
||||
|
||||
/* Dropped Cap */
|
||||
.drop-cap::first-letter {
|
||||
@apply text-6xl font-bold float-left mr-3 leading-[0.8] mt-2;
|
||||
font-family: 'PT Serif', serif;
|
||||
}
|
||||
|
||||
/* Wavy Highlight (Refined for Pixel Perfect) */
|
||||
.ms-highlight {
|
||||
@apply relative inline-block;
|
||||
text-decoration: underline wavy var(--accent);
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
/* Dot Grid Divider */
|
||||
.dot-divider {
|
||||
@apply w-full h-8 mb-6 md:mb-8;
|
||||
background-image: radial-gradient(circle, #d1d5db 1px, transparent 1px);
|
||||
background-size: 12px 12px;
|
||||
}
|
||||
|
||||
/* Card Styling (Technical Labels) */
|
||||
.tech-label {
|
||||
@apply border border-gray-100 bg-white p-4 transition-all duration-300;
|
||||
box-shadow: 2px 2px 0px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tech-label:hover {
|
||||
@apply border-blue-600 shadow-blue-100;
|
||||
box-shadow: 4px 4px 0px var(--accent-soft);
|
||||
transform: translate(-2px, -2px);
|
||||
}
|
||||
|
||||
/* Vertical Text Helper */
|
||||
.vertical-text {
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: white;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-soft);
|
||||
border: 4px solid white;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
10
src/main.jsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
28
tailwind.config.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
},
|
||||
animation: {
|
||||
'blob': 'blob 7s infinite',
|
||||
},
|
||||
keyframes: {
|
||||
blob: {
|
||||
'0%': { transform: 'translate(0px, 0px) scale(1)' },
|
||||
'33%': { transform: 'translate(30px, -50px) scale(1.1)' },
|
||||
'66%': { transform: 'translate(-20px, 20px) scale(0.9)' },
|
||||
'100%': { transform: 'translate(0px, 0px) scale(1)' },
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
11
vite.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
build: {
|
||||
sourcemap: false,
|
||||
},
|
||||
})
|
||||