Complete tetris grid layout with animations and mobile support
This commit is contained in:
parent
6a37da1ae5
commit
54e5018030
13 changed files with 1347 additions and 940 deletions
|
|
@ -5,7 +5,8 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>VNDANGKHOA</title>
|
<title>VNDANGKHOA — PORTAL</title>
|
||||||
|
<meta name="description" content="VNDANGKHOA, developer and creator. A portal to digital works and utilities." />
|
||||||
<meta http-equiv="Referrer-Policy" content="no-referrer" />
|
<meta http-equiv="Referrer-Policy" content="no-referrer" />
|
||||||
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
|
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
1262
package-lock.json
generated
1262
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -27,6 +27,6 @@
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"vite": "^5.4.21"
|
"vite": "^7.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
public/cv-video.mp4
Normal file
BIN
public/cv-video.mp4
Normal file
Binary file not shown.
411
src/App.jsx
411
src/App.jsx
|
|
@ -1,127 +1,306 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } 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';
|
|
||||||
|
|
||||||
// Interactive blueprint components
|
const COLORS_LIGHT = ['#7DD3D8', '#4A7BC7', '#F5A623', '#FFDC00', '#4CAF50', '#9C27B0', '#DC143C'];
|
||||||
import PrimaryBlueprint from './components/illustrations/PrimaryBlueprint';
|
const COLORS_DARK = ['#5AA0A5', '#3A6294', '#C88820', '#C8B000', '#3E8A40', '#7A2090', '#A01030'];
|
||||||
import MediaBlueprint from './components/illustrations/MediaBlueprint';
|
|
||||||
import MaintenanceBlueprint from './components/illustrations/MaintenanceBlueprint';
|
function getRandomColor(isDark) {
|
||||||
import DevToolsBlueprint from './components/illustrations/DevToolsBlueprint';
|
const colors = isDark ? COLORS_DARK : COLORS_LIGHT;
|
||||||
|
return colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TetrisPiece({ piece, isDark }) {
|
||||||
|
const [color, setColor] = useState(null);
|
||||||
|
const [showText, setShowText] = useState(false);
|
||||||
|
const isCV = piece.label === 'cv';
|
||||||
|
const isFeatured = piece.featured;
|
||||||
|
const alwaysShowText = true;
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (!isCV) setColor(piece.hoverColor);
|
||||||
|
setShowText(true);
|
||||||
|
};
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (!isCV) setColor(null);
|
||||||
|
setShowText(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bgColor = piece.label === 'cv' ? piece.color : piece.color;
|
||||||
|
const rowDelay = piece.startY * 4;
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
gridColumn: `${piece.startX + 1} / span ${piece.w}`,
|
||||||
|
gridRow: `${piece.startY + 1} / span ${piece.h}`,
|
||||||
|
backgroundColor: color || bgColor,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
aspectRatio: '1',
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
cursor: piece.link ? 'pointer' : 'default',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
animation: `dropIn 0.5s ease-out forwards, colorBlink 5s ease-in-out ${rowDelay}s infinite`,
|
||||||
|
animationFillMode: 'forwards, none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const className = 'tetris-piece show';
|
||||||
|
|
||||||
|
// CV block shows looping video
|
||||||
|
if (isCV) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={piece.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
opacity: 1,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<source src="/cv-video.mp4" type="video/mp4" />
|
||||||
|
</video>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '8px',
|
||||||
|
left: '8px',
|
||||||
|
fontSize: 'clamp(8px, 1.5vw, 12px)',
|
||||||
|
fontWeight: '500',
|
||||||
|
textTransform: 'lowercase',
|
||||||
|
color: '#fff',
|
||||||
|
opacity: 1,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
}}>
|
||||||
|
{piece.label}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text only shows on hover, positioned at bottom left
|
||||||
|
const textStyle = {
|
||||||
|
fontSize: 'clamp(8px, 1.5vw, 12px)',
|
||||||
|
fontWeight: '500',
|
||||||
|
textTransform: 'lowercase',
|
||||||
|
color: alwaysShowText ? '#fff' : '#000',
|
||||||
|
opacity: alwaysShowText ? 1 : (showText ? 1 : 0),
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '8px',
|
||||||
|
left: '8px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapperStyle = {
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (piece.link) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={piece.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<div style={wrapperStyle}>
|
||||||
|
<span style={textStyle}>{piece.label}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<div style={wrapperStyle}>
|
||||||
|
<span style={{...textStyle, cursor: 'default'}}>{piece.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [isDark, setIsDark] = useState(() => {
|
||||||
|
const hour = new Date().getHours();
|
||||||
// Enhanced links with categories and colors for the MakingSoftware look
|
return hour < 6 || hour >= 18;
|
||||||
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 [seed, setSeed] = useState(() => Math.random());
|
||||||
const filteredLinks = enhancedLinks.filter(link =>
|
|
||||||
link.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
const shuffleLayout = () => {
|
||||||
link.subtitle.toLowerCase().includes(searchQuery.toLowerCase())
|
const items = [
|
||||||
);
|
{ w: 4, h: 1, label: 'rm8pfix', link: 'https://rm8pfix.khoavo.myds.me', color: '#E8E8E8', hoverColor: '#00BCD4' },
|
||||||
|
{ w: 2, h: 2, label: 'netflix', link: 'https://nf.khoavo.myds.me', color: '#E8E8E8', hoverColor: '#FF9800' },
|
||||||
return (
|
{ w: 2, h: 3, label: 'portfolio', link: 'https://portfolio.khoavo.myds.me', featured: true, color: '#4A7BC7', hoverColor: '#2196F3' },
|
||||||
<div className="min-h-screen bg-white">
|
{ w: 2, h: 3, label: 'cv', link: 'https://cv.khoavo.myds.me', color: '#616161', hoverColor: '#424242' },
|
||||||
<Header />
|
{ w: 2, h: 2, label: 'youtube', link: 'https://ut.khoavo.myds.me', color: '#E8E8E8', hoverColor: '#FF5722' },
|
||||||
|
{ w: 2, h: 2, label: 'tiktok', link: 'https://tt.khoavo.myds.me', color: '#E8E8E8', hoverColor: '#9C27B0' },
|
||||||
|
{ w: 3, h: 2, label: 'spotify', link: 'https://sp.khoavo.myds.me', color: '#E8E8E8', hoverColor: '#4CAF50' },
|
||||||
|
{ w: 3, h: 2, label: 'tools', link: 'https://it.khoavo.myds.me', color: '#E8E8E8', hoverColor: '#FFC107' },
|
||||||
|
{ w: 2, h: 2, label: 'save', link: 'https://save.khoavo.myds.me', color: '#E8E8E8', hoverColor: '#E91E63' },
|
||||||
|
{ w: 2, h: 2, label: 'free', link: 'https://free.khoavo.myds.me', color: '#E8E8E8', hoverColor: '#00BCD4' },
|
||||||
|
{ w: 2, h: 2, label: 'jpg', link: 'https://jpg.khoavo.myds.me', color: '#E8E8E8', hoverColor: '#673AB7' },
|
||||||
|
{ w: 2, h: 2, label: 'pdf', link: 'https://pdf.khoavo.myds.me', color: '#E8E8E8', hoverColor: '#795548' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const random = (s) => {
|
||||||
|
const x = Math.sin(s * 9999) * 10000;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort items by size (larger first) to make placement easier
|
||||||
|
const sortedItems = [...items].sort((a, b) => (b.w * b.h) - (a.w * a.h));
|
||||||
|
|
||||||
|
// Shuffle the sorted items
|
||||||
|
for (let i = sortedItems.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(random(seed + i * 100) * (i + 1));
|
||||||
|
[sortedItems[i], sortedItems[j]] = [sortedItems[j], sortedItems[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridRows = 10;
|
||||||
|
const gridCols = 10;
|
||||||
|
const grid = Array(gridRows).fill(null).map(() => Array(gridCols).fill(0));
|
||||||
|
const placed = [];
|
||||||
|
|
||||||
|
for (const item of sortedItems) {
|
||||||
|
let fits = false;
|
||||||
|
let bestPos = null;
|
||||||
|
let minOverlap = Infinity;
|
||||||
|
|
||||||
<main>
|
// Try many random positions
|
||||||
{groups.map((group, groupIndex) => {
|
for (let attempt = 0; attempt < 500; attempt++) {
|
||||||
const groupLinks = filteredLinks
|
const r1 = random(seed + attempt * 7 + item.label.charCodeAt(0));
|
||||||
.filter(link => link.group === group.id)
|
const r2 = random(seed + attempt * 13 + item.label.charCodeAt(0));
|
||||||
.sort((a, b) => a.order - b.order);
|
const row = Math.floor(r1 * (gridRows - item.h + 1));
|
||||||
|
const col = Math.floor(r2 * (gridCols - item.w + 1));
|
||||||
if (groupLinks.length === 0) return null;
|
|
||||||
|
// Check if this position works
|
||||||
|
let canPlace = true;
|
||||||
|
let overlap = 0;
|
||||||
|
for (let rr = row; rr < row + item.h && canPlace; rr++) {
|
||||||
|
for (let cc = col; cc < col + item.w && canPlace; cc++) {
|
||||||
|
if (grid[rr] && grid[rr][cc] === 1) {
|
||||||
|
canPlace = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canPlace) {
|
||||||
|
for (let rr = row; rr < row + item.h; rr++) {
|
||||||
|
for (let cc = col; cc < col + item.w; cc++) {
|
||||||
|
grid[rr][cc] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placed.push({ ...item, startX: col, startY: row });
|
||||||
|
fits = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return placed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const layout = shuffleLayout();
|
||||||
|
const sortedLayout = [...layout].sort((a, b) => b.startY - a.startY);
|
||||||
|
|
||||||
|
const toggleTheme = () => setIsDark(!isDark);
|
||||||
|
const refreshLayout = () => setSeed(Math.random());
|
||||||
|
|
||||||
|
const bg = isDark ? '#1a1a1a' : '#ffffff';
|
||||||
|
const headerBg = isDark ? '#1a1a1a' : '#ffffff';
|
||||||
|
const textColor = isDark ? '#fff' : '#000';
|
||||||
|
const borderColor = isDark ? '#444' : '#000';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', background: bg, padding: '0 10px', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
<header style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: headerBg,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '8px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: '12px', flexWrap: 'wrap' }}>
|
||||||
|
<a href="/" style={{ fontSize: '14px', fontWeight: '500', color: textColor }}>Khoa.vo</a>
|
||||||
|
<span style={{ fontSize: '10px', color: isDark ? '#666' : '#999' }} className="header-tagline">where design meets intelligence</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={refreshLayout}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: `1px solid ${borderColor}`,
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: textColor,
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: `1px solid ${borderColor}`,
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: textColor,
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDark ? '☀' : '☾'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
const isPrimary = group.id === 'primary';
|
<main className="tetris-board" key={seed} style={{ background: isDark ? '#222' : '#fff', flex: '1 1 auto', minHeight: 0 }}>
|
||||||
|
{sortedLayout.map((piece, idx) => (
|
||||||
return (
|
<TetrisPiece key={piece.label} piece={piece} isDark={isDark} />
|
||||||
<React.Fragment key={group.id}>
|
))}
|
||||||
<Section
|
|
||||||
title={group.title}
|
|
||||||
description={group.description}
|
|
||||||
index={groupIndex}
|
|
||||||
isFeatured={isPrimary && !searchQuery}
|
|
||||||
featuredImage={
|
|
||||||
group.id === 'primary' ? <PrimaryBlueprint /> :
|
|
||||||
group.id === 'entertainment' ? <MediaBlueprint /> :
|
|
||||||
group.id === 'rm8pfix-vn' ? <MaintenanceBlueprint /> :
|
|
||||||
<DevToolsBlueprint />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{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-1 md:pb-2">
|
|
||||||
<div className="mb-1"><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-1"><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>
|
</main>
|
||||||
|
|
||||||
<footer className="ms-container border-t border-gray-100 mt-2 md:mt-4 pb-12 flex flex-col md:flex-row justify-between items-center md:items-end gap-8">
|
<footer style={{
|
||||||
<div className="flex items-center gap-2 w-full md:w-auto">
|
padding: '8px 20px',
|
||||||
<h2 className="font-pixel text-blue-600 text-xs md:text-sm">VNDANGKHOA_PORTAL</h2>
|
fontSize: '10px',
|
||||||
</div>
|
color: isDark ? '#888' : '#666',
|
||||||
|
width: '100%',
|
||||||
<div className="text-center md:text-right w-full md:w-auto">
|
maxWidth: '500px',
|
||||||
<p className="font-serif text-[10px] md:text-[11px] font-bold text-gray-900 italic">
|
margin: '0 auto',
|
||||||
"Software engineering is the most important trade of the 21st century."
|
textAlign: 'center',
|
||||||
</p>
|
flexShrink: 0,
|
||||||
<p className="font-mono text-[9px] text-gray-400 mt-2 tracking-widest">
|
}}>
|
||||||
© {new Date().getFullYear()} REPRODUCED PIXEL PERFECT
|
<p>© {new Date().getFullYear()} — Khoa.vo</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
86
src/components/FeaturedCard.jsx
Normal file
86
src/components/FeaturedCard.jsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
const FeaturedCard = ({ title, subtitle, url, type, videoSrc, index = 0 }) => {
|
||||||
|
return (
|
||||||
|
<motion.a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
initial={{ opacity: 0, y: -30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
whileHover={{
|
||||||
|
backgroundColor: 'var(--color-border-default)',
|
||||||
|
transition: { duration: 0.15 }
|
||||||
|
}}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.3,
|
||||||
|
delay: index * 0.1,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94]
|
||||||
|
}}
|
||||||
|
className="block p-3 md:p-4"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface-base)',
|
||||||
|
border: '1px solid var(--color-border-default)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col min-h-[100px]">
|
||||||
|
<div className="flex items-center justify-between mb-2 md:mb-3">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[9px] uppercase"
|
||||||
|
style={{ color: 'var(--color-text-secondary)' }}
|
||||||
|
>
|
||||||
|
featured
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="w-1 h-1"
|
||||||
|
style={{ backgroundColor: 'var(--color-text-primary)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type === 'cv' && videoSrc && (
|
||||||
|
<div
|
||||||
|
className="relative w-full h-12 md:h-16 mb-2 overflow-hidden"
|
||||||
|
style={{ border: '1px solid var(--color-border-default)' }}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
src={videoSrc}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
className="w-full h-full object-cover opacity-80"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3
|
||||||
|
className="font-mono text-sm md:text-base"
|
||||||
|
style={{ color: 'var(--color-text-primary)' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="font-mono text-[9px]"
|
||||||
|
style={{ color: 'var(--color-text-secondary)' }}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-auto pt-2">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[9px] uppercase"
|
||||||
|
style={{ color: 'var(--color-text-primary)' }}
|
||||||
|
>
|
||||||
|
enter
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturedCard;
|
||||||
|
|
@ -2,32 +2,19 @@ import React from 'react';
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-gray-100">
|
<header
|
||||||
<header className="ms-container py-6 md:py-10 flex flex-col lg:flex-row justify-between items-start lg:items-end gap-6">
|
className="fixed top-0 left-0 right-0 z-50 flex justify-between items-center px-6 py-4"
|
||||||
<div className="flex flex-col">
|
>
|
||||||
<h1 className="text-4xl sm:text-5xl md:text-7xl lg:text-[84px] text-blue-600 font-pixel tracking-[-0.05em] leading-[0.85] uppercase">
|
<a
|
||||||
VNDANGKHOA<br />PORTAL
|
href="/"
|
||||||
</h1>
|
className="font-mono text-sm"
|
||||||
</div>
|
>
|
||||||
|
Khoa.vo
|
||||||
<div className="flex flex-col text-left lg:text-right max-w-sm lg:mb-2">
|
</a>
|
||||||
<p className="font-serif text-[15px] md:text-[17px] leading-snug italic text-gray-900 font-medium">
|
<div className="flex gap-6">
|
||||||
A reference manual for people who design and build software.
|
|
||||||
</p>
|
|
||||||
<p className="font-mono text-[11px] md:text-[12px] font-bold mt-2 text-black uppercase tracking-[0.2em]">
|
|
||||||
Managed and illustrated by Khoa Vo.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Manual Divider Pattern */}
|
|
||||||
<div className="ms-container overflow-hidden whitespace-nowrap opacity-20 select-none pb-4" aria-hidden="true">
|
|
||||||
<div className="text-[10px] font-mono tracking-[0.5em] flex justify-between w-full">
|
|
||||||
{Array(20).fill('❖ ❖ ❖ ❖ ❖').join(' ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Header;
|
export default Header;
|
||||||
|
|
@ -1,47 +1,19 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
const PortalCard = ({ item, index }) => {
|
const PortalCard = ({ item, index }) => {
|
||||||
const { title, subtitle, url, color } = item;
|
const { title, subtitle, url } = item;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
initial={{ opacity: 0, y: 10 }}
|
className="portal-link"
|
||||||
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">
|
<span className="portal-title">{title}</span>
|
||||||
<div className="flex items-center justify-between">
|
{subtitle && <span className="portal-subtitle">{subtitle}</span>}
|
||||||
<span className="font-mono text-[11px] md:text-[12px] text-gray-400 font-bold uppercase tracking-[0.1em]">
|
</a>
|
||||||
[ PORT_ACCESS ]
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="w-1.5 h-1.5 rounded-full"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="font-mono text-[16px] md:text-[18px] font-extrabold text-black flex items-center gap-2 group-hover:text-blue-600 transition-colors mt-1">
|
|
||||||
<span className="opacity-40 group-hover:opacity-100 transition-opacity">→</span> {title.toUpperCase()}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="font-serif text-[13px] md:text-[14px] italic text-gray-500 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;
|
export default PortalCard;
|
||||||
|
|
@ -1,67 +1,41 @@
|
||||||
import React, { useRef } from 'react';
|
import React from 'react';
|
||||||
import { motion, useInView } from 'framer-motion';
|
import { motion } 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');
|
|
||||||
const isEven = index % 2 === 0;
|
|
||||||
|
|
||||||
|
const Section = ({ title, children }) => {
|
||||||
return (
|
return (
|
||||||
<div className={isFeatured ? "w-full bg-blue-50/20 border-y-2 border-blue-50/50 py-4 my-8 relative shadow-sm" : "w-full"}>
|
<motion.div
|
||||||
<section className={`ms-container relative py-8 md:py-16 flex flex-col ${isEven ? 'lg:flex-row' : 'lg:flex-row-reverse'} gap-8 lg:gap-16 items-start`}>
|
initial={{ opacity: 0, y: 15 }}
|
||||||
{/* Sidebar Label - Desktop Only */}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
<motion.div
|
viewport={{ once: true }}
|
||||||
initial={{ opacity: 0, x: isEven ? -10 : 10 }}
|
transition={{ duration: 0.2, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
>
|
||||||
viewport={{ once: true }}
|
<section className="px-3 md:px-5 py-4 md:py-5">
|
||||||
className={`hidden lg:block vertical-text absolute ${isEven ? '-left-8' : '-right-8'} top-0 text-[11px] font-mono text-gray-300 tracking-[0.3em] uppercase font-bold`}
|
<div className="max-w-[1100px] mx-auto">
|
||||||
>
|
{title && (
|
||||||
FIG. {figNumber}
|
<div className="mb-3 md:mb-4">
|
||||||
</motion.div>
|
<h2
|
||||||
|
className="font-mono text-[9px] uppercase"
|
||||||
{/* Content Column (Text & Buttons) */}
|
style={{ color: 'var(--color-text-primary)' }}
|
||||||
<div className="w-full lg:w-[45%] flex flex-col z-10 w-full relative">
|
>
|
||||||
<h2 className={`font-pixel text-blue-600 mb-6 tracking-wider uppercase leading-tight ${isFeatured ? 'text-3xl md:text-4xl lg:text-5xl drop-shadow-sm' : 'text-xl md:text-2xl'}`}>
|
{title}
|
||||||
{title}
|
</h2>
|
||||||
</h2>
|
<motion.div
|
||||||
|
className="h-[1px] w-6 mt-1"
|
||||||
<div className="manual-text drop-cap mb-8">
|
style={{ backgroundColor: 'var(--color-text-primary)' }}
|
||||||
{description}
|
initial={{ scaleX: 0 }}
|
||||||
</div>
|
whileInView={{ scaleX: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
transition={{ duration: 0.15, delay: 0.05 }}
|
||||||
{children}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Blueprint Column (Animation) */}
|
|
||||||
<div className="w-full lg:w-[55%]">
|
|
||||||
{featuredImage && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
whileInView={{ opacity: 1, scale: 1 }}
|
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
|
||||||
className="blueprint-container"
|
|
||||||
>
|
|
||||||
{typeof featuredImage === 'string' ? (
|
|
||||||
<img src={featuredImage} alt={title} className="w-full h-auto grayscale hover:grayscale-0 transition-all duration-700" />
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-auto grayscale hover:grayscale-0 transition-all duration-700">
|
|
||||||
{featuredImage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mt-3 flex justify-between items-center font-mono text-[9px] text-gray-400 uppercase tracking-widest px-1">
|
|
||||||
<span>SYSTEM_SCHEMATIC_V.1</span>
|
|
||||||
<span>ID_{figNumber}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 md:gap-3">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,37 +22,28 @@ export const links = [
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
title: "YouTube",
|
title: "YouTube",
|
||||||
subtitle: "No Ads",
|
subtitle: "Ad-free",
|
||||||
url: "https://ut.khoavo.myds.me",
|
url: "https://ut.khoavo.myds.me",
|
||||||
icon: Youtube,
|
icon: Youtube,
|
||||||
group: "entertainment",
|
group: "media",
|
||||||
order: 1
|
order: 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
title: "TikTok",
|
title: "TikTok",
|
||||||
subtitle: "No Ads",
|
subtitle: "Ad-free",
|
||||||
url: "https://tt.khoavo.myds.me",
|
url: "https://tt.khoavo.myds.me",
|
||||||
icon: Video,
|
icon: Video,
|
||||||
group: "entertainment",
|
group: "media",
|
||||||
order: 2
|
order: 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
title: "Music",
|
|
||||||
subtitle: "Streaming",
|
|
||||||
url: "https://music.khoavo.myds.me",
|
|
||||||
icon: Music,
|
|
||||||
group: "entertainment",
|
|
||||||
order: 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
title: "Spotify",
|
title: "Spotify",
|
||||||
subtitle: "Streaming",
|
subtitle: "Streaming",
|
||||||
url: "https://sp.khoavo.myds.me",
|
url: "https://sp.khoavo.myds.me",
|
||||||
icon: Music,
|
icon: Music,
|
||||||
group: "entertainment",
|
group: "media",
|
||||||
order: 4
|
order: 4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -61,16 +52,16 @@ export const links = [
|
||||||
subtitle: "Streaming",
|
subtitle: "Streaming",
|
||||||
url: "https://nf.khoavo.myds.me",
|
url: "https://nf.khoavo.myds.me",
|
||||||
icon: Film,
|
icon: Film,
|
||||||
group: "entertainment",
|
group: "media",
|
||||||
order: 5
|
order: 5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 8,
|
id: 8,
|
||||||
title: "RM8PFix",
|
title: "RM8PFix",
|
||||||
subtitle: "Fixes & Tools",
|
subtitle: "Tools",
|
||||||
url: "https://rm8pfix.khoavo.myds.me",
|
url: "https://rm8pfix.khoavo.myds.me",
|
||||||
icon: Wrench,
|
icon: Wrench,
|
||||||
group: "rm8pfix-vn",
|
group: "tools",
|
||||||
order: 1
|
order: 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -79,7 +70,7 @@ export const links = [
|
||||||
subtitle: "Save",
|
subtitle: "Save",
|
||||||
url: "https://save.khoavo.myds.me",
|
url: "https://save.khoavo.myds.me",
|
||||||
icon: HardDrive,
|
icon: HardDrive,
|
||||||
group: "rm8pfix-vn",
|
group: "tools",
|
||||||
order: 2
|
order: 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -88,7 +79,7 @@ export const links = [
|
||||||
subtitle: "Free",
|
subtitle: "Free",
|
||||||
url: "https://free.khoavo.myds.me",
|
url: "https://free.khoavo.myds.me",
|
||||||
icon: Wrench,
|
icon: Wrench,
|
||||||
group: "rm8pfix-vn",
|
group: "tools",
|
||||||
order: 3
|
order: 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -97,8 +88,8 @@ export const links = [
|
||||||
subtitle: "Tools",
|
subtitle: "Tools",
|
||||||
url: "https://pdf.khoavo.myds.me",
|
url: "https://pdf.khoavo.myds.me",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
group: "dev",
|
group: "tools",
|
||||||
order: 1
|
order: 4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 12,
|
id: 12,
|
||||||
|
|
@ -106,43 +97,55 @@ export const links = [
|
||||||
subtitle: "Tools",
|
subtitle: "Tools",
|
||||||
url: "https://jpg.khoavo.myds.me",
|
url: "https://jpg.khoavo.myds.me",
|
||||||
icon: Image,
|
icon: Image,
|
||||||
group: "dev",
|
group: "tools",
|
||||||
order: 2
|
order: 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: "Netflix",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 13,
|
id: 7,
|
||||||
|
title: "RM8PFix",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
title: "Portal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
title: "Free",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
title: "PDF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
title: "JPG",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
title: "IT Utilities",
|
title: "IT Utilities",
|
||||||
subtitle: "Dev Tools",
|
subtitle: "Dev Tools",
|
||||||
url: "https://it.khoavo.myds.me",
|
url: "https://it.khoavo.myds.me",
|
||||||
icon: Terminal,
|
icon: Terminal,
|
||||||
group: "dev",
|
group: "tools",
|
||||||
order: 3
|
order: 6
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const groups = [
|
export const groups = [
|
||||||
{
|
{
|
||||||
id: 'primary',
|
id: 'primary',
|
||||||
title: 'PRIMARY',
|
title: 'primary',
|
||||||
icon: Briefcase,
|
|
||||||
description: "Have you ever wondered how your digital presence is routed across different platforms? The primary resource layer acts as the centralized directory for all professional identities. These components are hard-coded for maximum reliability and persistent access to the core VNDK ecosystem."
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'entertainment',
|
id: 'media',
|
||||||
title: 'MEDIA_DISTRIBUTION',
|
title: 'media',
|
||||||
icon: Monitor,
|
|
||||||
description: "Digital consumption and leisure systems operate on a separate high-fidelity distribution plane. By utilizing unauthorized ad-blocking layers and clean streaming protocols, we ensure that media remains an uninterrupted experience within the portal."
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rm8pfix-vn',
|
id: 'tools',
|
||||||
title: 'SYSTEM_MAINTENANCE',
|
title: 'tools',
|
||||||
icon: Wrench,
|
|
||||||
description: "This terminal routes directly to the REDMAGIC hardware modification group. Representing flagship architecture (builds 8, 9, and 10 Pro), these devices feature top-tier Snapdragon processing and kernel-level flexibility, granting enthusiasts absolute administrative control to deeply customize their systems."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'dev',
|
|
||||||
title: 'DEVELOPMENT_STACK',
|
|
||||||
icon: Cpu,
|
|
||||||
description: "Because our software tools are built on primitive data types, we need a robust stack to handle rasterization, PDF serialization, and IT utility execution. This toolkit provides the necessary abstractions to manipulate system state at the binary level."
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
229
src/index.css
229
src/index.css
|
|
@ -1,113 +1,184 @@
|
||||||
@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');
|
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #ffffff;
|
--font-mono: 'IBM Plex Mono', monospace;
|
||||||
|
--cell-size: 9vw;
|
||||||
|
|
||||||
|
/* Light theme (default) */
|
||||||
|
--bg-cream: #ffffff;
|
||||||
|
--bg-board: #ffffff;
|
||||||
--text-primary: #000000;
|
--text-primary: #000000;
|
||||||
--text-secondary: #6b7280;
|
--text-secondary: #666666;
|
||||||
--accent: #3147ba;
|
--border-color: #000000;
|
||||||
--accent-soft: rgba(49, 71, 186, 0.05);
|
--cell-default: #c0c0c0;
|
||||||
--border-color: #e5e7eb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
[data-theme="dark"] {
|
||||||
body {
|
--bg-cream: #1a1a1a;
|
||||||
@apply bg-white text-black antialiased font-serif;
|
--bg-board: #222222;
|
||||||
background-image:
|
--text-primary: #ffffff;
|
||||||
linear-gradient(var(--accent-soft) 1px, transparent 1px),
|
--text-secondary: #888888;
|
||||||
linear-gradient(90deg, var(--accent-soft) 1px, transparent 1px);
|
--border-color: #444444;
|
||||||
background-size: 24px 24px;
|
--cell-default: #333333;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, .font-pixel {
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
font-family: 'Silkscreen', cursive;
|
|
||||||
}
|
|
||||||
|
|
||||||
p, .font-serif {
|
html, body {
|
||||||
font-family: 'PT Serif', serif;
|
width: 100%;
|
||||||
}
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
code, pre, .font-mono {
|
body {
|
||||||
font-family: 'IBM Plex Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
|
background: var(--bg-cream);
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--bg-cream);
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-name { font-size: 14px; font-weight: 500; }
|
||||||
|
.header-nav a { font-size: 10px; color: var(--text-secondary); margin-left: 16px; }
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(10, 1fr);
|
||||||
|
grid-template-rows: repeat(8, 1fr);
|
||||||
|
align-content: end;
|
||||||
|
gap: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 10px auto;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: var(--bg-board);
|
||||||
|
contain: layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.tetris-board {
|
||||||
|
gap: 0;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.tetris-piece {
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
.header-tagline {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Technical Layout Utilities */
|
@media (max-width: 500px) {
|
||||||
.ms-container {
|
.header-tagline {
|
||||||
@apply max-w-[1100px] mx-auto px-6 md:px-12;
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dropped Cap */
|
@media (max-width: 500px) {
|
||||||
.drop-cap::first-letter {
|
.footer {
|
||||||
@apply text-6xl font-bold float-left mr-3 leading-[0.8] mt-2;
|
width: 100% !important;
|
||||||
font-family: 'PT Serif', serif;
|
max-width: 100%;
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wavy Highlight (Refined for Pixel Perfect) */
|
html, body {
|
||||||
.ms-highlight {
|
width: 100%;
|
||||||
@apply relative inline-block;
|
overflow-x: hidden;
|
||||||
text-decoration: underline wavy var(--accent);
|
height: 100%;
|
||||||
text-underline-offset: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dot Grid Divider */
|
body {
|
||||||
.dot-divider {
|
font-family: var(--font-mono);
|
||||||
@apply w-full h-8 mb-4;
|
background: var(--bg-cream);
|
||||||
background-image: radial-gradient(circle, #e5e7eb 1.5px, transparent 1.5px);
|
|
||||||
background-size: 16px 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blueprint Image Container */
|
.tetris-piece {
|
||||||
.blueprint-container {
|
display: block;
|
||||||
@apply relative border border-gray-100 bg-white p-2 shadow-sm transition-all duration-500;
|
width: 100%;
|
||||||
background-image:
|
height: 100%;
|
||||||
linear-gradient(var(--accent-soft) 1px, transparent 1px),
|
transition: background 0.15s ease;
|
||||||
linear-gradient(90deg, var(--accent-soft) 1px, transparent 1px);
|
opacity: 0;
|
||||||
background-size: 16px 16px;
|
animation: dropIn 0.5s ease-out forwards;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
-webkit-border-radius: 0;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blueprint-container img {
|
.tetris-piece * {
|
||||||
@apply mix-blend-multiply opacity-90;
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.manual-text {
|
.tetris-piece.show {
|
||||||
@apply text-[17px] md:text-[18px] leading-[1.6] text-gray-900;
|
animation: dropIn 0.5s ease-out forwards;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card Styling (Technical Labels) */
|
@keyframes colorBlink {
|
||||||
.tech-label {
|
0%, 100% {
|
||||||
@apply border-2 border-gray-100 bg-white p-4 md:p-5 transition-all duration-300;
|
filter: brightness(1);
|
||||||
box-shadow: 2px 2px 0px rgba(0, 0, 0, 0.05);
|
}
|
||||||
|
50% {
|
||||||
|
filter: brightness(1.25) saturate(1.1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tech-label:hover {
|
@media (max-width: 500px) {
|
||||||
@apply border-blue-600 shadow-blue-100 bg-blue-50/10;
|
@keyframes colorBlink {
|
||||||
box-shadow: 6px 6px 0px var(--accent-soft);
|
0%, 100% {
|
||||||
transform: translate(-3px, -3px);
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: brightness(1.08) saturate(1.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vertical Text Helper */
|
@keyframes dropIn {
|
||||||
.vertical-text {
|
0% {
|
||||||
writing-mode: vertical-rl;
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
@keyframes colorBlink {
|
||||||
::-webkit-scrollbar {
|
0%, 100% {
|
||||||
width: 10px;
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: brightness(1.3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
.footer {
|
||||||
background: white;
|
padding: 10px 20px;
|
||||||
}
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
::-webkit-scrollbar-thumb {
|
width: calc(10 * var(--cell-size));
|
||||||
background: var(--accent-soft);
|
margin: 0 auto;
|
||||||
border: 4px solid white;
|
text-align: center;
|
||||||
}
|
margin-top: auto;
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,28 +1,11 @@
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
darkMode: 'class',
|
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
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: [],
|
plugins: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4,7 +4,14 @@ import react from '@vitejs/plugin-react'
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
base: './',
|
base: '/',
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 4173,
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue