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" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<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="X-Content-Type-Options" content="nosniff" />
|
||||
</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",
|
||||
"postcss": "^8.5.6",
|
||||
"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.
393
src/App.jsx
393
src/App.jsx
|
|
@ -1,127 +1,306 @@
|
|||
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';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
// Interactive blueprint components
|
||||
import PrimaryBlueprint from './components/illustrations/PrimaryBlueprint';
|
||||
import MediaBlueprint from './components/illustrations/MediaBlueprint';
|
||||
import MaintenanceBlueprint from './components/illustrations/MaintenanceBlueprint';
|
||||
import DevToolsBlueprint from './components/illustrations/DevToolsBlueprint';
|
||||
const COLORS_LIGHT = ['#7DD3D8', '#4A7BC7', '#F5A623', '#FFDC00', '#4CAF50', '#9C27B0', '#DC143C'];
|
||||
const COLORS_DARK = ['#5AA0A5', '#3A6294', '#C88820', '#C8B000', '#3E8A40', '#7A2090', '#A01030'];
|
||||
|
||||
function App() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
function getRandomColor(isDark) {
|
||||
const colors = isDark ? COLORS_DARK : COLORS_LIGHT;
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
}
|
||||
|
||||
// Enhanced links with categories and colors for the MakingSoftware look
|
||||
const enhancedLinks = links.map(link => {
|
||||
let color = '#3147ba'; // Primary Blue
|
||||
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;
|
||||
|
||||
if (link.group === 'entertainment') {
|
||||
color = '#ec4899';
|
||||
} else if (link.group === 'dev') {
|
||||
color = '#10b981';
|
||||
} else if (link.group === 'rm8pfix-vn') {
|
||||
color = '#f59e0b';
|
||||
}
|
||||
const handleMouseEnter = () => {
|
||||
if (!isCV) setColor(piece.hoverColor);
|
||||
setShowText(true);
|
||||
};
|
||||
const handleMouseLeave = () => {
|
||||
if (!isCV) setColor(null);
|
||||
setShowText(false);
|
||||
};
|
||||
|
||||
return {
|
||||
...link,
|
||||
color
|
||||
};
|
||||
});
|
||||
const bgColor = piece.label === 'cv' ? piece.color : piece.color;
|
||||
const rowDelay = piece.startY * 4;
|
||||
|
||||
const filteredLinks = enhancedLinks.filter(link =>
|
||||
link.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
link.subtitle.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
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="min-h-screen bg-white">
|
||||
<Header />
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div style={wrapperStyle}>
|
||||
<span style={{...textStyle, cursor: 'default'}}>{piece.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<main>
|
||||
{groups.map((group, groupIndex) => {
|
||||
const groupLinks = filteredLinks
|
||||
.filter(link => link.group === group.id)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
function App() {
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
const hour = new Date().getHours();
|
||||
return hour < 6 || hour >= 18;
|
||||
});
|
||||
const [seed, setSeed] = useState(() => Math.random());
|
||||
|
||||
if (groupLinks.length === 0) return null;
|
||||
const shuffleLayout = () => {
|
||||
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' },
|
||||
{ w: 2, h: 3, label: 'portfolio', link: 'https://portfolio.khoavo.myds.me', featured: true, color: '#4A7BC7', hoverColor: '#2196F3' },
|
||||
{ w: 2, h: 3, label: 'cv', link: 'https://cv.khoavo.myds.me', color: '#616161', hoverColor: '#424242' },
|
||||
{ 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 isPrimary = group.id === 'primary';
|
||||
const random = (s) => {
|
||||
const x = Math.sin(s * 9999) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
// Sort items by size (larger first) to make placement easier
|
||||
const sortedItems = [...items].sort((a, b) => (b.w * b.h) - (a.w * a.h));
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
// 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]];
|
||||
}
|
||||
|
||||
{groupIndex < groups.length - 1 && !isPrimary && <div className="mt-1"><DotDivider /></div>}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
const gridRows = 10;
|
||||
const gridCols = 10;
|
||||
const grid = Array(gridRows).fill(null).map(() => Array(gridCols).fill(0));
|
||||
const placed = [];
|
||||
|
||||
{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>
|
||||
)}
|
||||
for (const item of sortedItems) {
|
||||
let fits = false;
|
||||
let bestPos = null;
|
||||
let minOverlap = Infinity;
|
||||
|
||||
// Try many random positions
|
||||
for (let attempt = 0; attempt < 500; attempt++) {
|
||||
const r1 = random(seed + attempt * 7 + item.label.charCodeAt(0));
|
||||
const r2 = random(seed + attempt * 13 + item.label.charCodeAt(0));
|
||||
const row = Math.floor(r1 * (gridRows - item.h + 1));
|
||||
const col = Math.floor(r2 * (gridCols - item.w + 1));
|
||||
|
||||
// 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>
|
||||
|
||||
<main className="tetris-board" key={seed} style={{ background: isDark ? '#222' : '#fff', flex: '1 1 auto', minHeight: 0 }}>
|
||||
{sortedLayout.map((piece, idx) => (
|
||||
<TetrisPiece key={piece.label} piece={piece} isDark={isDark} />
|
||||
))}
|
||||
</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">
|
||||
<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 style={{
|
||||
padding: '8px 20px',
|
||||
fontSize: '10px',
|
||||
color: isDark ? '#888' : '#666',
|
||||
width: '100%',
|
||||
maxWidth: '500px',
|
||||
margin: '0 auto',
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<p>© {new Date().getFullYear()} — Khoa.vo</p>
|
||||
</footer>
|
||||
</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,31 +2,18 @@ import React from 'react';
|
|||
|
||||
const Header = () => {
|
||||
return (
|
||||
<div className="border-b border-gray-100">
|
||||
<header className="ms-container py-6 md:py-10 flex flex-col lg:flex-row justify-between items-start lg:items-end gap-6">
|
||||
<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">
|
||||
VNDANGKHOA<br />PORTAL
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col text-left lg:text-right max-w-sm lg:mb-2">
|
||||
<p className="font-serif text-[15px] md:text-[17px] leading-snug italic text-gray-900 font-medium">
|
||||
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>
|
||||
<header
|
||||
className="fixed top-0 left-0 right-0 z-50 flex justify-between items-center px-6 py-4"
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
className="font-mono text-sm"
|
||||
>
|
||||
Khoa.vo
|
||||
</a>
|
||||
<div className="flex gap-6">
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,46 +1,18 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const PortalCard = ({ item, index }) => {
|
||||
const { title, subtitle, url, color } = item;
|
||||
const { title, subtitle, url } = item;
|
||||
|
||||
return (
|
||||
<motion.a
|
||||
<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"
|
||||
className="portal-link"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[11px] md:text-[12px] text-gray-400 font-bold uppercase tracking-[0.1em]">
|
||||
[ 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>
|
||||
<span className="portal-title">{title}</span>
|
||||
{subtitle && <span className="portal-subtitle">{subtitle}</span>}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,67 +1,41 @@
|
|||
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');
|
||||
const isEven = index % 2 === 0;
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const Section = ({ title, children }) => {
|
||||
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"}>
|
||||
<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`}>
|
||||
{/* Sidebar Label - Desktop Only */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: isEven ? -10 : 10 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
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`}
|
||||
>
|
||||
FIG. {figNumber}
|
||||
</motion.div>
|
||||
|
||||
{/* Content Column (Text & Buttons) */}
|
||||
<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}
|
||||
</h2>
|
||||
|
||||
<div className="manual-text drop-cap mb-8">
|
||||
{description}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{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>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.2, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
<section className="px-3 md:px-5 py-4 md:py-5">
|
||||
<div className="max-w-[1100px] mx-auto">
|
||||
{title && (
|
||||
<div className="mb-3 md:mb-4">
|
||||
<h2
|
||||
className="font-mono text-[9px] uppercase"
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<motion.div
|
||||
className="h-[1px] w-6 mt-1"
|
||||
style={{ backgroundColor: 'var(--color-text-primary)' }}
|
||||
initial={{ scaleX: 0 }}
|
||||
whileInView={{ scaleX: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.15, delay: 0.05 }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 md:gap-3">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,37 +22,28 @@ export const links = [
|
|||
{
|
||||
id: 3,
|
||||
title: "YouTube",
|
||||
subtitle: "No Ads",
|
||||
subtitle: "Ad-free",
|
||||
url: "https://ut.khoavo.myds.me",
|
||||
icon: Youtube,
|
||||
group: "entertainment",
|
||||
group: "media",
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "TikTok",
|
||||
subtitle: "No Ads",
|
||||
subtitle: "Ad-free",
|
||||
url: "https://tt.khoavo.myds.me",
|
||||
icon: Video,
|
||||
group: "entertainment",
|
||||
group: "media",
|
||||
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",
|
||||
group: "media",
|
||||
order: 4
|
||||
},
|
||||
{
|
||||
|
|
@ -61,16 +52,16 @@ export const links = [
|
|||
subtitle: "Streaming",
|
||||
url: "https://nf.khoavo.myds.me",
|
||||
icon: Film,
|
||||
group: "entertainment",
|
||||
group: "media",
|
||||
order: 5
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: "RM8PFix",
|
||||
subtitle: "Fixes & Tools",
|
||||
subtitle: "Tools",
|
||||
url: "https://rm8pfix.khoavo.myds.me",
|
||||
icon: Wrench,
|
||||
group: "rm8pfix-vn",
|
||||
group: "tools",
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
|
|
@ -79,7 +70,7 @@ export const links = [
|
|||
subtitle: "Save",
|
||||
url: "https://save.khoavo.myds.me",
|
||||
icon: HardDrive,
|
||||
group: "rm8pfix-vn",
|
||||
group: "tools",
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
|
|
@ -88,7 +79,7 @@ export const links = [
|
|||
subtitle: "Free",
|
||||
url: "https://free.khoavo.myds.me",
|
||||
icon: Wrench,
|
||||
group: "rm8pfix-vn",
|
||||
group: "tools",
|
||||
order: 3
|
||||
},
|
||||
{
|
||||
|
|
@ -97,8 +88,8 @@ export const links = [
|
|||
subtitle: "Tools",
|
||||
url: "https://pdf.khoavo.myds.me",
|
||||
icon: FileText,
|
||||
group: "dev",
|
||||
order: 1
|
||||
group: "tools",
|
||||
order: 4
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
|
|
@ -106,43 +97,55 @@ export const links = [
|
|||
subtitle: "Tools",
|
||||
url: "https://jpg.khoavo.myds.me",
|
||||
icon: Image,
|
||||
group: "dev",
|
||||
order: 2
|
||||
group: "tools",
|
||||
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",
|
||||
subtitle: "Dev Tools",
|
||||
url: "https://it.khoavo.myds.me",
|
||||
icon: Terminal,
|
||||
group: "dev",
|
||||
order: 3
|
||||
group: "tools",
|
||||
order: 6
|
||||
},
|
||||
];
|
||||
|
||||
export const groups = [
|
||||
{
|
||||
id: '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."
|
||||
title: 'primary',
|
||||
},
|
||||
{
|
||||
id: 'entertainment',
|
||||
title: 'MEDIA_DISTRIBUTION',
|
||||
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: 'media',
|
||||
title: 'media',
|
||||
},
|
||||
{
|
||||
id: 'rm8pfix-vn',
|
||||
title: 'SYSTEM_MAINTENANCE',
|
||||
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."
|
||||
id: 'tools',
|
||||
title: 'tools',
|
||||
},
|
||||
];
|
||||
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');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&display=swap');
|
||||
|
||||
: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-secondary: #6b7280;
|
||||
--accent: #3147ba;
|
||||
--accent-soft: rgba(49, 71, 186, 0.05);
|
||||
--border-color: #e5e7eb;
|
||||
--text-secondary: #666666;
|
||||
--border-color: #000000;
|
||||
--cell-default: #c0c0c0;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--bg-cream: #1a1a1a;
|
||||
--bg-board: #222222;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #888888;
|
||||
--border-color: #444444;
|
||||
--cell-default: #333333;
|
||||
}
|
||||
|
||||
h1, h2, h3, .font-pixel {
|
||||
font-family: 'Silkscreen', cursive;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
p, .font-serif {
|
||||
font-family: 'PT Serif', serif;
|
||||
}
|
||||
html, body {
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
code, pre, .font-mono {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
body {
|
||||
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 */
|
||||
.ms-container {
|
||||
@apply max-w-[1100px] mx-auto px-6 md:px-12;
|
||||
@media (max-width: 500px) {
|
||||
.header-tagline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@media (max-width: 500px) {
|
||||
.footer {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Wavy Highlight (Refined for Pixel Perfect) */
|
||||
.ms-highlight {
|
||||
@apply relative inline-block;
|
||||
text-decoration: underline wavy var(--accent);
|
||||
text-underline-offset: 4px;
|
||||
html, body {
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Dot Grid Divider */
|
||||
.dot-divider {
|
||||
@apply w-full h-8 mb-4;
|
||||
background-image: radial-gradient(circle, #e5e7eb 1.5px, transparent 1.5px);
|
||||
background-size: 16px 16px;
|
||||
body {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--bg-cream);
|
||||
}
|
||||
|
||||
/* Blueprint Image Container */
|
||||
.blueprint-container {
|
||||
@apply relative border border-gray-100 bg-white p-2 shadow-sm transition-all duration-500;
|
||||
background-image:
|
||||
linear-gradient(var(--accent-soft) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--accent-soft) 1px, transparent 1px);
|
||||
background-size: 16px 16px;
|
||||
.tetris-piece {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: background 0.15s ease;
|
||||
opacity: 0;
|
||||
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 {
|
||||
@apply mix-blend-multiply opacity-90;
|
||||
.tetris-piece * {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.manual-text {
|
||||
@apply text-[17px] md:text-[18px] leading-[1.6] text-gray-900;
|
||||
.tetris-piece.show {
|
||||
animation: dropIn 0.5s ease-out forwards;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
/* Card Styling (Technical Labels) */
|
||||
.tech-label {
|
||||
@apply border-2 border-gray-100 bg-white p-4 md:p-5 transition-all duration-300;
|
||||
box-shadow: 2px 2px 0px rgba(0, 0, 0, 0.05);
|
||||
@keyframes colorBlink {
|
||||
0%, 100% {
|
||||
filter: brightness(1);
|
||||
}
|
||||
50% {
|
||||
filter: brightness(1.25) saturate(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.tech-label:hover {
|
||||
@apply border-blue-600 shadow-blue-100 bg-blue-50/10;
|
||||
box-shadow: 6px 6px 0px var(--accent-soft);
|
||||
transform: translate(-3px, -3px);
|
||||
@media (max-width: 500px) {
|
||||
@keyframes colorBlink {
|
||||
0%, 100% {
|
||||
filter: brightness(1);
|
||||
}
|
||||
50% {
|
||||
filter: brightness(1.08) saturate(1.02);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Vertical Text Helper */
|
||||
.vertical-text {
|
||||
writing-mode: vertical-rl;
|
||||
@keyframes dropIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
@keyframes colorBlink {
|
||||
0%, 100% {
|
||||
filter: brightness(1);
|
||||
}
|
||||
50% {
|
||||
filter: brightness(1.3);
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: white;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-soft);
|
||||
border: 4px solid white;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent);
|
||||
.footer {
|
||||
padding: 10px 20px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
width: calc(10 * var(--cell-size));
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
|
@ -1,28 +1,11 @@
|
|||
/** @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)' },
|
||||
}
|
||||
}
|
||||
},
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,14 @@ import react from '@vitejs/plugin-react'
|
|||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
base: '/',
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
},
|
||||
preview: {
|
||||
port: 4173,
|
||||
},
|
||||
build: {
|
||||
sourcemap: false,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue