Turn template into Keith Rana's portfolio with real content, photos and bundled font
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
This commit is contained in:
38 files changed
+491
-271
No files matched your search
@@ -1,16 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="description" content="Jack — a 3D creator crafting striking and unforgettable projects." />
|
|
||||||
<title>Jack -- 3D Creator</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
// Builds ONE self-contained HTML file for the Claude cloud preview.
|
|
||||||
// Claude's cloud pages block outside images, so every image is downloaded
|
|
||||||
// here and baked into the page as a data: URI.
|
|
||||||
// Usage: node scripts/build-preview.mjs <output.html>
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
||||||
|
|
||||||
const out = process.argv[2] ?? "jack-preview.html";
|
|
||||||
import { buildSync } from 'esbuild';
|
|
||||||
import { rmSync, writeFileSync as write } from 'node:fs';
|
|
||||||
|
|
||||||
// 1. Read the real list of image URLs from src/data/content.ts
|
|
||||||
buildSync({ entryPoints: ['src/data/content.ts'], bundle: true, format: 'esm', outfile: 'dist-tmp/content.mjs', logLevel: 'error' });
|
|
||||||
const content = await import(new URL('../dist-tmp/content.mjs', import.meta.url).href);
|
|
||||||
rmSync('dist-tmp', { recursive: true, force: true });
|
|
||||||
const urls = new Set();
|
|
||||||
const walk = (v) => {
|
|
||||||
if (typeof v === 'string' && v.startsWith('https://')) urls.add(v);
|
|
||||||
else if (v && typeof v === 'object') Object.values(v).forEach(walk);
|
|
||||||
};
|
|
||||||
walk(content);
|
|
||||||
|
|
||||||
// 2. Download each one and turn it into a data: URI
|
|
||||||
const mime = { png: 'image/png', gif: 'image/gif', webp: 'image/webp', jpg: 'image/jpeg' };
|
|
||||||
const map = {};
|
|
||||||
let total = 0;
|
|
||||||
for (const url of urls) {
|
|
||||||
try {
|
|
||||||
const buf = execFileSync('curl', ['-sSfL', '-m', '60', url], { maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
||||||
const head = buf.subarray(0, 12).toString('latin1');
|
|
||||||
const type = head.startsWith('GIF') ? 'gif' : head.includes('WEBP') ? 'webp' : head.startsWith('\x89PNG') ? 'png' : 'jpg';
|
|
||||||
map[url] = `data:${mime[type]};base64,${buf.toString('base64')}`;
|
|
||||||
total += buf.length;
|
|
||||||
} catch {
|
|
||||||
console.warn('could not download:', url.slice(0, 90));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`images embedded: ${Object.keys(map).length}/${urls.size} (${(total / 1048576).toFixed(1)} MB)`);
|
|
||||||
|
|
||||||
// 3. Build with the pictures baked in, then put the empty lookup file back
|
|
||||||
const stub = 'src/data/embedded.ts';
|
|
||||||
const original = readFileSync(stub, 'utf8');
|
|
||||||
write(stub, `export const embedded: Record<string, string> = ${JSON.stringify(map)};\n`);
|
|
||||||
try {
|
|
||||||
execFileSync('npm', ['run', 'build'], { stdio: 'inherit' });
|
|
||||||
} finally {
|
|
||||||
write(stub, original);
|
|
||||||
}
|
|
||||||
const dir = 'dist/assets/';
|
|
||||||
const files = readdirSync(dir);
|
|
||||||
let js = readFileSync(dir + files.find((f) => f.endsWith('.js')), 'utf8');
|
|
||||||
const css = readFileSync(dir + files.find((f) => f.endsWith('.css')), 'utf8');
|
|
||||||
|
|
||||||
js = js.replace(/<\/script/g, '<\\/script');
|
|
||||||
const html = `<title>Jack 3D Creator</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
|
||||||
<style>${css}
|
|
||||||
html,body{background:#0C0C0C}
|
|
||||||
img{background:linear-gradient(135deg,#17171c,#24242c)}</style>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script>document.addEventListener("error",function(e){var t=e.target;if(t&&t.tagName==="IMG"){t.removeAttribute("src");t.alt="";}},true);</script>
|
|
||||||
<script type="module">${js}</script>
|
|
||||||
`;
|
|
||||||
writeFileSync(out, html);
|
|
||||||
const mb = Buffer.byteLength(html) / 1048576;
|
|
||||||
console.log(`wrote ${out} (${mb.toFixed(1)} MB)${mb > 16 ? ' WARNING: over the 16 MB page limit' : ''}`);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export default function LiveProjectButton() {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="whitespace-nowrap rounded-full border-2 border-[#D7E2EA] px-8 py-3 text-sm font-medium uppercase tracking-widest text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10 sm:px-10 sm:py-3.5 sm:text-base"
|
|
||||||
>
|
|
||||||
Live Project
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { embedded } from './embedded';
|
|
||||||
|
|
||||||
const A = (url: string) => embedded[url] ?? url;
|
|
||||||
|
|
||||||
const FIGMA = 'https://shrug-person-78902957.figma.site/_components/v2';
|
|
||||||
const ABOUT = `${FIGMA}/ebb2b8f25d8e24d5f0a5ca8af4c950de81aa2fd7`;
|
|
||||||
|
|
||||||
export const portrait = A(`${FIGMA}/d24c01ad3a56fc65e942a1f501eb73db42d7cf9a/Rectangle_40443.81459862.png`);
|
|
||||||
|
|
||||||
export const aboutIcons = {
|
|
||||||
moon: A(`${ABOUT}/moon_icon.11395d36.png`),
|
|
||||||
object: A(`${ABOUT}/p59_1.4659672e.png`),
|
|
||||||
lego: A(`${ABOUT}/lego_icon-1.703bb594.png`),
|
|
||||||
group: A(`${ABOUT}/Group_134-1.2e04f3ce.png`),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const aboutText =
|
|
||||||
"With more than five years of experience in design, i focus on branding, web design, and user experience, i truly enjoy working with businesses that aim to stand out and present their best image. Let's build something incredible together!";
|
|
||||||
|
|
||||||
export const marqueeImages = [
|
|
||||||
'hero-space-voyage-preview-eECLH3Yc',
|
|
||||||
'hero-codenest-preview-Cgppc2qV',
|
|
||||||
'hero-vex-ventures-preview-BczMFIiw',
|
|
||||||
'hero-stellar-ai-v2-preview-DjvxjG3C',
|
|
||||||
'hero-asme-preview-B_nGDnTP',
|
|
||||||
'hero-transform-data-preview-Cx5OU29N',
|
|
||||||
'hero-vitara-preview-Cjz2QYyU',
|
|
||||||
'hero-terra-preview-BFjrCr7T',
|
|
||||||
'hero-skyelite-preview-DHaZIgUv',
|
|
||||||
'hero-aethera-preview-DknSlcTa',
|
|
||||||
'hero-designpro-preview-D8c5_een',
|
|
||||||
'hero-stellar-ai-preview-D3HL6bw1',
|
|
||||||
'hero-xportfolio-preview-D4A8maiC',
|
|
||||||
'hero-orbit-web3-preview-BXt4OttD',
|
|
||||||
'hero-nexora-preview-cx5HmUgo',
|
|
||||||
'hero-evr-ventures-preview-DZxeVFEX',
|
|
||||||
'hero-planet-orbit-preview-DWAP8Z1P',
|
|
||||||
'hero-new-era-preview-CocuDUm9',
|
|
||||||
'hero-wealth-preview-B70idl_u',
|
|
||||||
'hero-luminex-preview-CxOP7ce6',
|
|
||||||
'hero-celestia-preview-0yO3jXO8',
|
|
||||||
].map((n) => A(`https://motionsites.ai/assets/${n}.gif`));
|
|
||||||
|
|
||||||
export const services = [
|
|
||||||
{ name: '3D Modeling', description: 'Creation of detailed objects, characters, or environments tailored to specific client needs, ideal for games, products, and visualizations.' },
|
|
||||||
{ name: 'Rendering', description: 'High-quality, photorealistic renders that showcase designs with custom lighting, textures, and materials to bring concepts to life.' },
|
|
||||||
{ name: 'Motion Design', description: 'Dynamic animations and motion graphics that add energy and storytelling to brands, products, and digital experiences.' },
|
|
||||||
{ name: 'Branding', description: 'Crafting cohesive visual identities -- from logos to full brand systems -- that communicate a clear and memorable presence.' },
|
|
||||||
{ name: 'Web Design', description: 'Designing clean, modern, and conversion-focused websites with attention to layout, typography, and user experience.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const cf = (file: string) =>
|
|
||||||
A(`https://images.higgs.ai/?default=1&output=webp&url=${encodeURIComponent(
|
|
||||||
`https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/${file}`
|
|
||||||
)}&w=1280&q=85`);
|
|
||||||
|
|
||||||
export const projects = [
|
|
||||||
{
|
|
||||||
category: 'Client',
|
|
||||||
name: 'Nextlevel Studio',
|
|
||||||
col1: [cf('hf_20260412_055344_5eff02e0-87a5-41ce-b64f-eb08da8f33db.png'), cf('hf_20260412_055431_11d841fd-8b41-46a5-82e4-b04f2407a7d8.png')],
|
|
||||||
col2: cf('hf_20260412_055451_e317bf2d-28d4-48cc-86b0-6f72f25b6327.png'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: 'Personal',
|
|
||||||
name: 'Aura Brand Identity',
|
|
||||||
col1: [cf('hf_20260412_055654_911201c5-36d9-4bc6-bac7-331adfce159f.png'), cf('hf_20260412_055723_5ceda0b8-d9c2-4665-b2e3-83ba19ba76d1.png')],
|
|
||||||
col2: cf('hf_20260412_055753_adc5dcbd-a8e6-49c0-b43a-9b030d835cea.png'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: 'Client',
|
|
||||||
name: 'Solaris Digital',
|
|
||||||
col1: [cf('hf_20260412_055759_963cfb0b-4bd1-4b0f-9d0a-09bd6cf95b2f.png'), cf('hf_20260412_060108_438f781a-9846-4dcc-89ab-c4e6cb830f5b.png')],
|
|
||||||
col2: cf('hf_20260412_055818_9d062121-ad7e-46b9-999a-1a6a692ef1ee.png'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Filled in automatically by scripts/build-preview.mjs (image URL -> data: URI).
|
|
||||||
// Empty in normal use, so the site loads images from their original URLs.
|
|
||||||
export const embedded: Record<string, string> = {};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { marqueeImages } from '../data/content';
|
|
||||||
|
|
||||||
const row1 = marqueeImages.slice(0, 11);
|
|
||||||
const row2 = marqueeImages.slice(11);
|
|
||||||
|
|
||||||
function Row({ images, transform }: { images: string[]; transform: string }) {
|
|
||||||
const tripled = [...images, ...images, ...images];
|
|
||||||
return (
|
|
||||||
<div className="flex gap-3" style={{ transform, willChange: 'transform' }}>
|
|
||||||
{tripled.map((src, i) => (
|
|
||||||
<img
|
|
||||||
key={i}
|
|
||||||
src={src}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
className="rounded-2xl object-cover"
|
|
||||||
style={{ width: 420, height: 270, flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MarqueeSection() {
|
|
||||||
const ref = useRef<HTMLElement>(null);
|
|
||||||
const [offset, setOffset] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onScroll = () => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
const top = ref.current.getBoundingClientRect().top + window.scrollY;
|
|
||||||
setOffset((window.scrollY - top + window.innerHeight) * 0.3);
|
|
||||||
};
|
|
||||||
onScroll();
|
|
||||||
window.addEventListener('scroll', onScroll, { passive: true });
|
|
||||||
return () => window.removeEventListener('scroll', onScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section ref={ref} className="flex flex-col gap-3 bg-[#0C0C0C] pb-10 pt-24 sm:pt-32 md:pt-40" style={{ overflow: 'clip' }}>
|
|
||||||
<Row images={row1} transform={`translateX(${offset - 200}px)`} />
|
|
||||||
<Row images={row2} transform={`translateX(${-(offset - 200)}px)`} />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
base: './',
|
|
||||||
});
|
|
||||||
File renamed without changes.
@@ -1,9 +1,9 @@
|
|||||||
# PRD: "Jack — 3D Creator" Portfolio Template
|
# PRD: Keith Rana Portfolio (built from the "Jack 3D Creator" template)
|
||||||
|
|
||||||
## The restaurant analogy
|
## The restaurant analogy
|
||||||
| Stage | Restaurant | This project |
|
| Stage | Restaurant | This project |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Phase 1 (today) | Food stand: one great dish, open today | The full single-page site, looks right, animations work |
|
| Phase 1 (done) | Food stand: one great dish, open today | The full single-page site with Keith's real content, photos and animations |
|
||||||
| Phase 2 | Small restaurant: a few tables, a real menu | Make it easy to edit (content in one file), better mobile + accessibility |
|
| Phase 2 | Small restaurant: a few tables, a real menu | Make it easy to edit (content in one file), better mobile + accessibility |
|
||||||
| Phase 3 | Full restaurant: front of house + back of house | Real contact form, email, project pages, CMS, analytics |
|
| Phase 3 | Full restaurant: front of house + back of house | Real contact form, email, project pages, CMS, analytics |
|
||||||
|
|
||||||
@@ -33,3 +33,13 @@ Done when: builds with no errors; page loads with no console errors; every butto
|
|||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
Phase 1 has no forms, no logins, no secrets, so risk is low. External links use rel="noopener noreferrer". When Phase 3 adds a form, validation and spam protection must live on the server, and API keys must never be in front-end code.
|
Phase 1 has no forms, no logins, no secrets, so risk is low. External links use rel="noopener noreferrer". When Phase 3 adds a form, validation and spam protection must live on the server, and API keys must never be in front-end code.
|
||||||
|
|
||||||
|
## Phase 1 status (done)
|
||||||
|
- Content comes from Keith's existing portfolio: profile, 8 capabilities, 3 roles + 5 earlier roles, 4 projects, 10 certifications, contact details (all in `src/data/content.ts`).
|
||||||
|
- Sections: Hero (portrait), scrolling photo/skills rows, About, Capability, Experience, Projects (stacking cards), Credentials, Contact.
|
||||||
|
- Photos and the Kanit font ship inside the site (no outside image hosts needed).
|
||||||
|
- Tested at 360, 390, 768, 1024, 1440 and 1920 px wide: no sideways scroll, no console errors, all images load.
|
||||||
|
- Cloud preview: `node scripts/build-preview.mjs out.html` builds one self-contained page.
|
||||||
|
|
||||||
|
## Not used yet
|
||||||
|
- `it_helpdesk.db` (87 IT help-desk FAQs) was supplied but is not portfolio content. Candidate for a Phase 2 FAQ section if wanted.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Keith Rana, IT Service Delivery Manager in Melbourne, Australia." />
|
||||||
|
<title>Keith Rana -- IT Service Delivery</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "jack-portfolio",
|
"name": "keith-portfolio",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "jack-portfolio",
|
"name": "keith-portfolio",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/kanit": "^5.3.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -710,6 +711,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/kanit": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/kanit/-/kanit-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-XrVxf5NWb8QLAEbQS2GYir3AY0WbOLr5Q80fWurnoFWXyc7CWvioGNPC2dxRGZKocMUXbwGhuK+sJqWkRLAWNA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "jack-portfolio",
|
"name": "keith-portfolio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/kanit": "^5.3.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
File renamed without changes.
@@ -0,0 +1,24 @@
|
|||||||
|
// Builds ONE self-contained HTML page for the Claude cloud preview.
|
||||||
|
// Claude's cloud pages block outside images and files, so photos and fonts are baked in.
|
||||||
|
// Usage: node scripts/build-preview.mjs <output.html>
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const out = process.argv[2] ?? 'preview.html';
|
||||||
|
execFileSync('npm', ['run', 'build'], { stdio: 'inherit', env: { ...process.env, INLINE: '1' } });
|
||||||
|
|
||||||
|
const dir = 'dist/assets/';
|
||||||
|
const files = readdirSync(dir);
|
||||||
|
const js = readFileSync(dir + files.find((f) => f.endsWith('.js')), 'utf8').replace(/<\/script/g, '<\\/script');
|
||||||
|
const css = readFileSync(dir + files.find((f) => f.endsWith('.css')), 'utf8');
|
||||||
|
|
||||||
|
const html = `<meta charset="utf-8">
|
||||||
|
<title>Keith Rana Portfolio</title>
|
||||||
|
<style>${css}
|
||||||
|
html,body{background:#0C0C0C}</style>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module">${js}</script>
|
||||||
|
`;
|
||||||
|
writeFileSync(out, html);
|
||||||
|
const mb = Buffer.byteLength(html) / 1048576;
|
||||||
|
console.log(`wrote ${out} (${mb.toFixed(1)} MB)${mb > 16 ? ' WARNING: over the 16 MB page limit' : ''}`);
|
||||||
@@ -2,7 +2,10 @@ import HeroSection from './sections/HeroSection';
|
|||||||
import MarqueeSection from './sections/MarqueeSection';
|
import MarqueeSection from './sections/MarqueeSection';
|
||||||
import AboutSection from './sections/AboutSection';
|
import AboutSection from './sections/AboutSection';
|
||||||
import ServicesSection from './sections/ServicesSection';
|
import ServicesSection from './sections/ServicesSection';
|
||||||
|
import ExperienceSection from './sections/ExperienceSection';
|
||||||
import ProjectsSection from './sections/ProjectsSection';
|
import ProjectsSection from './sections/ProjectsSection';
|
||||||
|
import CredentialsSection from './sections/CredentialsSection';
|
||||||
|
import ContactSection from './sections/ContactSection';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -11,7 +14,10 @@ export default function App() {
|
|||||||
<MarqueeSection />
|
<MarqueeSection />
|
||||||
<AboutSection />
|
<AboutSection />
|
||||||
<ServicesSection />
|
<ServicesSection />
|
||||||
|
<ExperienceSection />
|
||||||
<ProjectsSection />
|
<ProjectsSection />
|
||||||
|
<CredentialsSection />
|
||||||
|
<ContactSection />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 641 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 277 KiB |
File renamed without changes.
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
|
import { profile } from "../data/content";
|
||||||
|
|
||||||
export default function ContactButton() {
|
export default function ContactButton() {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href="mailto:hello@example.com"
|
href={`mailto:${profile.email}`}
|
||||||
className="inline-block whitespace-nowrap rounded-full px-8 py-3 text-xs font-medium uppercase tracking-widest text-white sm:px-10 sm:py-3.5 sm:text-sm md:px-12 md:py-4 md:text-base"
|
className="inline-block whitespace-nowrap rounded-full px-8 py-3 text-xs font-medium uppercase tracking-widest text-white sm:px-10 sm:py-3.5 sm:text-sm md:px-12 md:py-4 md:text-base"
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(123deg, #18011F 7%, #B600A8 37%, #7621B0 72%, #BE4C00 100%)',
|
background: 'linear-gradient(123deg, #18011F 7%, #B600A8 37%, #7621B0 72%, #BE4C00 100%)',
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,130 @@
|
|||||||
|
import portrait from '../assets/portrait.jpg';
|
||||||
|
import leadership from '../assets/leadership.webp';
|
||||||
|
import systems from '../assets/systems.webp';
|
||||||
|
import signalMark from '../assets/signal-mark.webp';
|
||||||
|
|
||||||
|
export const images = { portrait, leadership, systems, signalMark };
|
||||||
|
|
||||||
|
export const profile = {
|
||||||
|
name: 'Keith Rana',
|
||||||
|
firstName: 'keith',
|
||||||
|
role: 'IT Service Delivery Manager',
|
||||||
|
location: 'Melbourne, Australia',
|
||||||
|
email: 'keith_rana@yahoo.com',
|
||||||
|
phone: '04 1668 7558',
|
||||||
|
phoneHref: 'tel:+61416687558',
|
||||||
|
tagline: 'an it service delivery manager keeping critical services steady while the work keeps moving',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const aboutText =
|
||||||
|
'A results-driven IT professional with over 16 years of experience across telecommunications, education, and managed services, including more than 8 years leading technical teams. I make technology service delivery visible, stable, and continually better. Let\'s make delivery more dependable.';
|
||||||
|
|
||||||
|
export const stats = [
|
||||||
|
{ value: '16+', label: 'Years in IT' },
|
||||||
|
{ value: '8+', label: 'Years leading' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const expertise = [
|
||||||
|
{ name: 'IT Service Delivery', description: 'End-to-end accountability for service delivery against agreed SLAs, with reporting to senior stakeholders.' },
|
||||||
|
{ name: 'Enterprise Infrastructure', description: 'Enterprise network, server and endpoint environments, including Fortinet, Zscaler and SCCM.' },
|
||||||
|
{ name: 'ITIL & Change Control', description: 'ITIL process, measured change and service reporting that improve compliance and service quality.' },
|
||||||
|
{ name: 'Incident & Escalation Leadership', description: 'Major incident management, workload prioritisation and escalations led with a calm, evidence-based approach.' },
|
||||||
|
{ name: 'Vendor & Stakeholder Management', description: 'Clear ownership of the vendor relationships that sit behind every service the business relies on.' },
|
||||||
|
{ name: 'Microsoft 365 & Intune', description: 'Hands-on depth in Microsoft 365, Azure and Intune across cloud applications and end-user devices.' },
|
||||||
|
{ name: 'Cybersecurity & Access', description: 'Security, identity and access practices built into daily operations, backed by Microsoft security certifications.' },
|
||||||
|
{ name: 'Team Development', description: 'Coaching, performance conversations, training and mentoring that grow capable, confident support teams.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const roles = [
|
||||||
|
{
|
||||||
|
dates: 'Oct 2023 — Present',
|
||||||
|
role: 'IT Service Delivery Manager',
|
||||||
|
organisation: 'AFL Telecommunications',
|
||||||
|
summary: 'Leading the daily delivery of business-critical IT services, balancing prompt resolution, availability, security, and a dependable experience for end users.',
|
||||||
|
points: [
|
||||||
|
'Own service operations across cloud applications, Windows and Linux environments, desktops, virtual apps, networks, and end-user support.',
|
||||||
|
'Lead a support and service-desk team through coaching, performance conversations, workload prioritisation, and escalations.',
|
||||||
|
'Use incident patterns, service-performance reporting, change controls, and feedback loops to improve ITIL compliance and service quality.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dates: 'Dec 2021 — Sep 2023',
|
||||||
|
role: 'Information Technology Manager',
|
||||||
|
organisation: 'Suzanne Cory High School',
|
||||||
|
summary: 'Managed technology services, documentation, knowledge systems, infrastructure capacity, and Microsoft 365 operations in a fast-moving education environment.',
|
||||||
|
points: [
|
||||||
|
'Oversaw Microsoft 365 services, endpoint operations, security, quality, and the administration and maintenance of core IT infrastructure.',
|
||||||
|
'Led a skilled IT specialist team with a focus on strategic direction, mentoring, service effectiveness, and superior customer outcomes.',
|
||||||
|
'Coordinated systems migrations, upgrades, and deployments to defined scope and schedule constraints.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dates: 'May 2018 — Nov 2021',
|
||||||
|
role: 'IT Support Specialist — Team Lead',
|
||||||
|
organisation: 'Wesley College Melbourne',
|
||||||
|
summary: 'Directed a six-person IT support team and helped translate organisational priorities into reliable, well-documented technical service.',
|
||||||
|
points: [
|
||||||
|
'Established project scope, supported IT initiatives, and aligned delivery activity with strategic objectives.',
|
||||||
|
'Worked across Casper Management, AirWatch MDM, Microsoft Intune, and operational processes informed by ITIL principles.',
|
||||||
|
'Identified skills gaps, organised training, coordinated with project managers, and maintained clear project documentation.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const foundations = [
|
||||||
|
{ role: 'IT Support Officer', organisation: 'Mentone Grammar School', dates: 'Apr 2017 — Dec 2017' },
|
||||||
|
{ role: 'IT Project Manager', organisation: 'Archirayan Infotech', dates: 'Aug 2015 — Jul 2016' },
|
||||||
|
{ role: 'IT Technical Support', organisation: 'Has Tech Solutions', dates: 'Aug 2014 — Jul 2015' },
|
||||||
|
{ role: 'IT Technical Engineer', organisation: 'Hindustan Cyber Services', dates: 'Oct 2012 — Aug 2014' },
|
||||||
|
{ role: 'Assistant Systems Analyst', organisation: 'Prutha Technologies', dates: 'Oct 2009 — Sep 2010' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const projects = [
|
||||||
|
{
|
||||||
|
category: 'Network',
|
||||||
|
name: 'Network project',
|
||||||
|
body: 'Fortinet Firewall upgrade, Fortinet 48P Switches upgrade, Fortinet access point uplift, and network topology upgrade.',
|
||||||
|
delivered: ['Fortinet firewall upgrade', 'Fortinet 48P switches upgrade', 'Fortinet access point uplift', 'Network topology upgrade'],
|
||||||
|
images: [systems, leadership] as [string, string],
|
||||||
|
position: ['center', 'center'] as [string, string],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Infrastructure',
|
||||||
|
name: 'Server upgrade',
|
||||||
|
body: 'Upgraded the server with existing VMs and services, migrated data, and configured updated network settings.',
|
||||||
|
delivered: ['Server upgraded with existing VMs and services', 'Data migrated', 'Updated network settings configured'],
|
||||||
|
images: [leadership, systems] as [string, string],
|
||||||
|
position: ['70% 40%', '20% 60%'] as [string, string],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Endpoint',
|
||||||
|
name: 'SCCM implementation',
|
||||||
|
body: 'Installed and configured SCCM, prepared the required settings, and established the task-sequence process.',
|
||||||
|
delivered: ['SCCM installed and configured', 'Required settings prepared', 'Task-sequence process established'],
|
||||||
|
images: [systems, leadership] as [string, string],
|
||||||
|
position: ['80% 20%', '30% 70%'] as [string, string],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Voice',
|
||||||
|
name: 'Microsoft Teams calling',
|
||||||
|
body: 'Delivered number-porting support, call-flow and dial-plan design, user training, documentation, and adoption monitoring.',
|
||||||
|
delivered: ['Number-porting support', 'Call-flow and dial-plan design', 'User training and documentation', 'Adoption monitoring'],
|
||||||
|
images: [leadership, systems] as [string, string],
|
||||||
|
position: ['50% 80%', '60% 30%'] as [string, string],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const certifications = [
|
||||||
|
'Introduction to Cyber Security',
|
||||||
|
'Windows Client',
|
||||||
|
'Microsoft Teams Voice Engineer',
|
||||||
|
'M365 Teams Administrator Associate',
|
||||||
|
'Microsoft Azure Administrator Associate',
|
||||||
|
'Microsoft Azure Security Engineer Associate',
|
||||||
|
'M365 Security Administrator Associate',
|
||||||
|
'Microsoft Information Protection Administrator Associate',
|
||||||
|
'Project Management Essentials Certified',
|
||||||
|
'Cloud Computing',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const education = { degree: 'Bachelor of Information Technology', school: 'Federation University', year: '2009' };
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
@import '@fontsource/kanit/latin-300.css';
|
||||||
|
@import '@fontsource/kanit/latin-400.css';
|
||||||
|
@import '@fontsource/kanit/latin-500.css';
|
||||||
|
@import '@fontsource/kanit/latin-700.css';
|
||||||
|
@import '@fontsource/kanit/latin-900.css';
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
@@ -5,6 +10,7 @@
|
|||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
html, body, #root { background: #0C0C0C; }
|
html, body, #root { background: #0C0C0C; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
body { font-family: 'Kanit', sans-serif; }
|
body { font-family: 'Kanit', sans-serif; }
|
||||||
|
|
||||||
.hero-heading {
|
.hero-heading {
|
||||||
@@ -13,3 +19,7 @@ body { font-family: 'Kanit', sans-serif; }
|
|||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
|
}
|
||||||
File renamed without changes.
+17
-10
@@ -1,22 +1,29 @@
|
|||||||
import FadeIn from '../components/FadeIn';
|
import FadeIn from '../components/FadeIn';
|
||||||
import AnimatedText from '../components/AnimatedText';
|
import AnimatedText from '../components/AnimatedText';
|
||||||
import ContactButton from '../components/ContactButton';
|
import ContactButton from '../components/ContactButton';
|
||||||
import { aboutIcons, aboutText } from '../data/content';
|
import { aboutText, images, stats } from '../data/content';
|
||||||
|
|
||||||
export default function AboutSection() {
|
export default function AboutSection() {
|
||||||
return (
|
return (
|
||||||
<section id="about" className="relative flex min-h-screen flex-col items-center justify-center gap-16 px-5 py-20 sm:gap-20 sm:px-8 md:gap-24 md:px-10">
|
<section id="about" className="relative flex min-h-screen flex-col items-center justify-center gap-16 px-5 py-20 sm:gap-20 sm:px-8 md:gap-24 md:px-10">
|
||||||
<FadeIn delay={0.1} x={-80} y={0} duration={0.9} className="absolute left-[1%] top-[4%] w-[120px] sm:left-[2%] sm:w-[160px] md:left-[4%] md:w-[210px]">
|
<FadeIn delay={0.1} x={-80} y={0} duration={0.9} className="absolute left-[1%] top-[3%] w-[90px] sm:left-[2%] sm:w-[130px] md:left-[4%] md:w-[170px]">
|
||||||
<img src={aboutIcons.moon} alt="" className="w-full" />
|
<img src={images.signalMark} alt="" className="aspect-square w-full rounded-2xl object-cover" />
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
<FadeIn delay={0.25} x={-80} y={0} duration={0.9} className="absolute bottom-[8%] left-[3%] w-[100px] sm:left-[6%] sm:w-[140px] md:left-[10%] md:w-[180px]">
|
<FadeIn delay={0.25} x={-80} y={0} duration={0.9} className="absolute bottom-[4%] left-[3%] w-[100px] sm:left-[6%] sm:w-[150px] md:left-[10%] md:w-[200px]">
|
||||||
<img src={aboutIcons.object} alt="" className="w-full" />
|
<img src={images.systems} alt="" className="aspect-[4/3] w-full -rotate-6 rounded-2xl object-cover" />
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
<FadeIn delay={0.15} x={80} y={0} duration={0.9} className="absolute right-[1%] top-[4%] w-[120px] sm:right-[2%] sm:w-[160px] md:right-[4%] md:w-[210px]">
|
<FadeIn delay={0.15} x={80} y={0} duration={0.9} className="absolute right-[1%] top-[3%] w-[100px] sm:right-[2%] sm:w-[150px] md:right-[4%] md:w-[210px]">
|
||||||
<img src={aboutIcons.lego} alt="" className="w-full" />
|
<img src={images.leadership} alt="" className="aspect-[3/2] w-full rotate-6 rounded-2xl object-cover" />
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
<FadeIn delay={0.3} x={80} y={0} duration={0.9} className="absolute bottom-[8%] right-[3%] w-[130px] sm:right-[6%] sm:w-[170px] md:right-[10%] md:w-[220px]">
|
<FadeIn delay={0.3} x={80} y={0} duration={0.9} className="absolute bottom-[4%] right-[3%] sm:right-[6%] md:right-[10%]">
|
||||||
<img src={aboutIcons.group} alt="" className="w-full" />
|
<div className="flex gap-2 sm:gap-3">
|
||||||
|
{stats.map((s) => (
|
||||||
|
<div key={s.label} className="rounded-2xl border border-[#D7E2EA]/30 px-2.5 py-2 text-center sm:px-5 sm:py-3">
|
||||||
|
<div className="hero-heading text-2xl font-black leading-none sm:text-5xl md:text-6xl">{s.value}</div>
|
||||||
|
<div className="mt-1 whitespace-nowrap text-[9px] font-light uppercase tracking-wide text-[#D7E2EA] sm:text-xs">{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
|
|
||||||
<div className="relative z-10 flex flex-col items-center gap-10 sm:gap-14 md:gap-16">
|
<div className="relative z-10 flex flex-col items-center gap-10 sm:gap-14 md:gap-16">
|
||||||
@@ -31,7 +38,7 @@ export default function AboutSection() {
|
|||||||
style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}
|
style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10">
|
<div className="relative z-10 mb-24 sm:mb-28 md:mb-0">
|
||||||
<ContactButton />
|
<ContactButton />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ArrowUpRight, Phone } from 'lucide-react';
|
||||||
|
import FadeIn from '../components/FadeIn';
|
||||||
|
import ContactButton from '../components/ContactButton';
|
||||||
|
import { profile } from '../data/content';
|
||||||
|
|
||||||
|
export default function ContactSection() {
|
||||||
|
return (
|
||||||
|
<section id="contact" className="flex flex-col items-center gap-10 px-5 pb-10 pt-20 sm:gap-14 sm:px-8 md:px-10 md:pt-28">
|
||||||
|
<FadeIn>
|
||||||
|
<h2 className="hero-heading text-center font-black uppercase leading-none tracking-tight" style={{ fontSize: 'clamp(2.6rem, 10vw, 140px)' }}>
|
||||||
|
Let’s talk
|
||||||
|
</h2>
|
||||||
|
</FadeIn>
|
||||||
|
<FadeIn>
|
||||||
|
<p className="max-w-[560px] text-center font-medium leading-relaxed text-[#D7E2EA]" style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}>
|
||||||
|
Based in {profile.location}. Available for the right conversation.
|
||||||
|
</p>
|
||||||
|
</FadeIn>
|
||||||
|
<FadeIn>
|
||||||
|
<ContactButton />
|
||||||
|
</FadeIn>
|
||||||
|
<FadeIn className="w-full max-w-3xl">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<a href={`mailto:${profile.email}`} className="flex min-w-0 items-center justify-between gap-3 rounded-3xl border border-[#D7E2EA]/25 p-5 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-xs font-light uppercase tracking-widest opacity-70">Email</span>
|
||||||
|
<strong className="block break-words font-medium">{profile.email}</strong>
|
||||||
|
</span>
|
||||||
|
<ArrowUpRight size={20} className="shrink-0" />
|
||||||
|
</a>
|
||||||
|
<a href={profile.phoneHref} className="flex min-w-0 items-center justify-between gap-3 rounded-3xl border border-[#D7E2EA]/25 p-5 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-xs font-light uppercase tracking-widest opacity-70">Phone</span>
|
||||||
|
<strong className="block font-medium">{profile.phone}</strong>
|
||||||
|
</span>
|
||||||
|
<Phone size={18} className="shrink-0" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</FadeIn>
|
||||||
|
<footer className="mt-10 flex w-full flex-wrap justify-between gap-2 border-t border-[#D7E2EA]/20 pt-6 text-xs font-light uppercase tracking-wider text-[#D7E2EA]/70">
|
||||||
|
<span>{profile.name}</span>
|
||||||
|
<span>{profile.role}</span>
|
||||||
|
<span>{profile.location}</span>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import FadeIn from '../components/FadeIn';
|
||||||
|
import { certifications, education } from '../data/content';
|
||||||
|
|
||||||
|
export default function CredentialsSection() {
|
||||||
|
return (
|
||||||
|
<section id="credentials" className="px-5 py-20 sm:px-8 sm:py-24 md:px-10 md:py-32">
|
||||||
|
<FadeIn>
|
||||||
|
<h2 className="hero-heading mb-10 text-center font-black uppercase leading-none tracking-tight sm:mb-14" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||||
|
Credentials
|
||||||
|
</h2>
|
||||||
|
</FadeIn>
|
||||||
|
<FadeIn>
|
||||||
|
<div className="mx-auto mb-12 flex max-w-5xl flex-col items-center gap-1 text-center text-[#D7E2EA] sm:mb-16">
|
||||||
|
<span className="text-xs font-light uppercase tracking-widest opacity-70 sm:text-sm">Education</span>
|
||||||
|
<strong className="font-medium uppercase" style={{ fontSize: 'clamp(1.1rem, 2.2vw, 2.1rem)' }}>{education.degree}</strong>
|
||||||
|
<span className="font-light opacity-70">{education.school}, {education.year}</span>
|
||||||
|
</div>
|
||||||
|
</FadeIn>
|
||||||
|
<div className="mx-auto grid max-w-5xl gap-3 sm:grid-cols-2">
|
||||||
|
{certifications.map((c, i) => (
|
||||||
|
<FadeIn key={c} delay={(i % 2) * 0.08}>
|
||||||
|
<div className="flex h-full items-center gap-4 rounded-3xl border border-[#D7E2EA]/25 p-5 sm:p-6">
|
||||||
|
<span className="hero-heading text-3xl font-black leading-none sm:text-4xl">{String(i + 1).padStart(2, '0')}</span>
|
||||||
|
<p className="min-w-0 font-medium uppercase leading-snug text-[#D7E2EA]" style={{ fontSize: 'clamp(0.85rem, 1.4vw, 1.1rem)' }}>{c}</p>
|
||||||
|
</div>
|
||||||
|
</FadeIn>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import FadeIn from '../components/FadeIn';
|
||||||
|
import { foundations, roles } from '../data/content';
|
||||||
|
|
||||||
|
export default function ExperienceSection() {
|
||||||
|
return (
|
||||||
|
<section id="experience" className="bg-white px-5 pb-28 pt-20 sm:px-8 sm:pb-32 sm:pt-24 md:px-10 md:pb-40 md:pt-32">
|
||||||
|
<FadeIn>
|
||||||
|
<h2 className="mb-16 text-center font-black uppercase leading-none text-[#0C0C0C] sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||||
|
Experience
|
||||||
|
</h2>
|
||||||
|
</FadeIn>
|
||||||
|
<div className="mx-auto max-w-5xl">
|
||||||
|
{roles.map((r, i) => (
|
||||||
|
<FadeIn key={r.role + r.organisation}>
|
||||||
|
<article className="grid gap-4 py-8 sm:py-10 md:grid-cols-[220px_1fr] md:gap-12 md:py-12" style={{ borderTop: '1px solid rgba(12, 12, 12, 0.15)' }}>
|
||||||
|
<div className="flex flex-col gap-1 text-[#0C0C0C]">
|
||||||
|
<span className="text-sm font-medium uppercase tracking-wider opacity-60">{r.dates}</span>
|
||||||
|
<span className="font-black uppercase leading-tight" style={{ fontSize: 'clamp(1.1rem, 1.8vw, 1.5rem)' }}>{r.organisation}</span>
|
||||||
|
<span className="text-sm font-light opacity-60">{String(i + 1).padStart(2, '0')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col gap-4 text-[#0C0C0C]">
|
||||||
|
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{r.role}</h3>
|
||||||
|
<p className="max-w-2xl font-light leading-relaxed opacity-70" style={{ fontSize: 'clamp(0.9rem, 1.6vw, 1.2rem)' }}>{r.summary}</p>
|
||||||
|
<ul className="flex max-w-2xl flex-col gap-3">
|
||||||
|
{r.points.map((p) => (
|
||||||
|
<li key={p} className="flex gap-3 font-light leading-relaxed" style={{ fontSize: 'clamp(0.85rem, 1.4vw, 1.05rem)' }}>
|
||||||
|
<Check size={18} className="mt-1 shrink-0" />
|
||||||
|
<span>{p}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</FadeIn>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<FadeIn>
|
||||||
|
<div className="grid gap-6 py-10 md:grid-cols-[220px_1fr] md:gap-12 md:py-12" style={{ borderTop: '1px solid rgba(12, 12, 12, 0.15)' }}>
|
||||||
|
<h3 className="text-sm font-medium uppercase tracking-wider text-[#0C0C0C] opacity-60">Earlier foundations</h3>
|
||||||
|
<div className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||||
|
{foundations.map((f) => (
|
||||||
|
<div key={f.role + f.organisation} className="flex min-w-0 flex-col text-[#0C0C0C]">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider opacity-60">{f.dates}</span>
|
||||||
|
<strong className="font-medium uppercase">{f.role}</strong>
|
||||||
|
<span className="font-light opacity-70">{f.organisation}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FadeIn>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
-12
@@ -1,20 +1,20 @@
|
|||||||
import FadeIn from '../components/FadeIn';
|
import FadeIn from '../components/FadeIn';
|
||||||
import Magnet from '../components/Magnet';
|
import Magnet from '../components/Magnet';
|
||||||
import ContactButton from '../components/ContactButton';
|
import ContactButton from '../components/ContactButton';
|
||||||
import { portrait } from '../data/content';
|
import { images, profile } from '../data/content';
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ label: 'About', href: '#about' },
|
{ label: 'About', href: '#about' },
|
||||||
{ label: 'Price', href: '#services' },
|
{ label: 'Skills', href: '#skills' },
|
||||||
{ label: 'Projects', href: '#projects' },
|
{ label: 'Projects', href: '#projects' },
|
||||||
{ label: 'Contact', href: 'mailto:hello@example.com' },
|
{ label: 'Contact', href: '#contact' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function HeroSection() {
|
export default function HeroSection() {
|
||||||
return (
|
return (
|
||||||
<section className="relative flex h-screen flex-col" style={{ overflowX: 'clip' }}>
|
<section className="relative flex h-screen min-h-[600px] flex-col" style={{ overflowX: 'clip' }}>
|
||||||
<FadeIn delay={0} y={-20}>
|
<FadeIn delay={0} y={-20}>
|
||||||
<nav className="flex justify-between px-6 pt-6 md:px-10 md:pt-8">
|
<nav className="flex justify-between px-6 pt-6 md:px-10 md:pt-8" aria-label="Section navigation">
|
||||||
{links.map((l) => (
|
{links.map((l) => (
|
||||||
<a
|
<a
|
||||||
key={l.label}
|
key={l.label}
|
||||||
@@ -29,27 +29,32 @@ export default function HeroSection() {
|
|||||||
|
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<FadeIn delay={0.15} y={40}>
|
<FadeIn delay={0.15} y={40}>
|
||||||
<h1 className="hero-heading mt-6 w-full whitespace-nowrap text-center text-[14vw] font-black uppercase leading-none tracking-tight sm:mt-4 sm:text-[15vw] md:-mt-5 md:text-[16vw] lg:text-[17.5vw]">
|
<h1 className="hero-heading mt-6 w-full whitespace-nowrap text-center text-[16.5vw] font-black uppercase leading-none tracking-tight sm:mt-4 md:-mt-5">
|
||||||
Hi, i’m jack
|
Hi, i’m {profile.firstName}
|
||||||
</h1>
|
</h1>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute left-1/2 top-1/2 z-10 w-[280px] -translate-x-1/2 -translate-y-1/2 sm:bottom-0 sm:top-auto sm:w-[360px] sm:translate-y-0 md:w-[440px] lg:w-[520px]">
|
<div className="absolute left-1/2 top-1/2 z-10 w-[min(280px,52vh)] -translate-x-1/2 -translate-y-1/2 sm:bottom-0 sm:top-auto sm:w-[min(360px,56vh)] sm:translate-y-0 md:w-[min(440px,58vh)] lg:w-[min(520px,58vh)]">
|
||||||
<FadeIn delay={0.6} y={30}>
|
<FadeIn delay={0.6} y={30}>
|
||||||
<Magnet padding={150} strength={3} activeTransition="transform 0.3s ease-out" inactiveTransition="transform 0.6s ease-in-out" className="!block">
|
<Magnet padding={150} strength={3} activeTransition="transform 0.3s ease-out" inactiveTransition="transform 0.6s ease-in-out" className="!block">
|
||||||
<img src={portrait} alt="Portrait of Jack" className="w-full" />
|
<img
|
||||||
|
src={images.portrait}
|
||||||
|
alt="Keith Rana at his work desk"
|
||||||
|
className="aspect-square w-full border border-[#D7E2EA]/30 object-cover object-top"
|
||||||
|
style={{ borderRadius: '999px 999px 32px 32px' }}
|
||||||
|
/>
|
||||||
</Magnet>
|
</Magnet>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto flex items-end justify-between px-6 pb-7 sm:pb-8 md:px-10 md:pb-10">
|
<div className="mt-auto flex items-end justify-between gap-4 px-6 pb-7 sm:pb-8 md:px-10 md:pb-10">
|
||||||
<FadeIn delay={0.35} y={20}>
|
<FadeIn delay={0.35} y={20}>
|
||||||
<p
|
<p
|
||||||
className="max-w-[160px] font-light uppercase leading-snug tracking-wide text-[#D7E2EA] sm:max-w-[220px] md:max-w-[260px]"
|
className="max-w-[160px] font-light uppercase leading-snug tracking-wide text-[#D7E2EA] sm:max-w-[220px] md:max-w-[260px]"
|
||||||
style={{ fontSize: 'clamp(0.75rem, 1.4vw, 1.5rem)' }}
|
style={{ fontSize: 'clamp(0.7rem, 1.4vw, 1.5rem)' }}
|
||||||
>
|
>
|
||||||
a 3d creator driven by crafting striking and unforgettable projects
|
{profile.tagline}
|
||||||
</p>
|
</p>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
<FadeIn delay={0.5} y={20} className="relative z-20">
|
<FadeIn delay={0.5} y={20} className="relative z-20">
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { expertise, images } from '../data/content';
|
||||||
|
|
||||||
|
const TILE_W = 420;
|
||||||
|
const GAP = 12;
|
||||||
|
|
||||||
|
const photoTiles: { src: string; alt: string; position: string }[] = [
|
||||||
|
{ src: images.portrait, alt: 'Keith Rana at his work desk', position: 'center 20%' },
|
||||||
|
{ src: images.leadership, alt: 'Hands organising a service-delivery briefing', position: 'center' },
|
||||||
|
{ src: images.systems, alt: 'Abstract composition of connected service pathways', position: 'center' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = photoTiles.map((t) => (
|
||||||
|
<img
|
||||||
|
key={t.alt}
|
||||||
|
src={t.src}
|
||||||
|
alt={t.alt}
|
||||||
|
loading="lazy"
|
||||||
|
className="h-[270px] w-[420px] shrink-0 rounded-2xl object-cover"
|
||||||
|
style={{ objectPosition: t.position }}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
const skills = expertise.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={e.name}
|
||||||
|
className="flex h-[270px] w-[420px] shrink-0 flex-col justify-between rounded-2xl border border-[#D7E2EA]/25 p-6"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)' }}
|
||||||
|
>
|
||||||
|
<span className="hero-heading text-5xl font-black leading-none">{String(i + 1).padStart(2, '0')}</span>
|
||||||
|
<span className="text-2xl font-medium uppercase leading-tight text-[#D7E2EA]">{e.name}</span>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
function Row({ tiles, setWidth, transform }: { tiles: ReactNode[]; setWidth: number; transform: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex" style={{ gap: GAP, marginLeft: -setWidth, transform, willChange: 'transform' }} aria-hidden={tiles === skills}>
|
||||||
|
{[0, 1, 2].map((copy) => (
|
||||||
|
<div key={copy} className="flex" style={{ gap: GAP }}>
|
||||||
|
{tiles}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarqueeSection() {
|
||||||
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const top = ref.current.getBoundingClientRect().top + window.scrollY;
|
||||||
|
setOffset((window.scrollY - top + window.innerHeight) * 0.3);
|
||||||
|
};
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', onScroll);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', onScroll);
|
||||||
|
window.removeEventListener('resize', onScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const width = (n: number) => n * (TILE_W + GAP) + GAP;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={ref} className="flex flex-col gap-3 bg-[#0C0C0C] pb-10 pt-24 sm:pt-32 md:pt-40" style={{ overflow: 'clip' }}>
|
||||||
|
<Row tiles={photos} setWidth={width(photos.length)} transform={`translateX(${offset - 200}px)`} />
|
||||||
|
<Row tiles={skills} setWidth={width(skills.length)} transform={`translateX(${-(offset - 200)}px)`} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+20
-10
@@ -1,7 +1,7 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
import FadeIn from '../components/FadeIn';
|
import FadeIn from '../components/FadeIn';
|
||||||
import LiveProjectButton from '../components/LiveProjectButton';
|
|
||||||
import { projects } from '../data/content';
|
import { projects } from '../data/content';
|
||||||
|
|
||||||
type Project = (typeof projects)[number];
|
type Project = (typeof projects)[number];
|
||||||
@@ -15,30 +15,40 @@ function ProjectCard({ project, index, total }: { project: Project; index: numbe
|
|||||||
const scale = useTransform(scrollYProgress, [0, 1], [1, targetScale]);
|
const scale = useTransform(scrollYProgress, [0, 1], [1, targetScale]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="sticky top-24 h-[85vh] md:top-32">
|
<div ref={ref} className="sticky top-24 min-h-[85vh]">
|
||||||
<motion.div
|
<motion.div
|
||||||
className={`border-2 border-[#D7E2EA] bg-[#0C0C0C] p-4 sm:p-6 md:p-8 ${radius}`}
|
className={`border-2 border-[#D7E2EA] bg-[#0C0C0C] p-4 sm:p-6 md:p-8 ${radius}`}
|
||||||
style={{ scale, top: `${index * 28}px`, position: 'relative', transformOrigin: 'top center' }}
|
style={{ scale, top: `${index * 28}px`, position: 'relative', transformOrigin: 'top center' }}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-4 sm:mb-6">
|
<div className="mb-4 flex flex-col gap-3 sm:mb-6 md:flex-row md:items-center md:justify-between md:gap-10">
|
||||||
<div className="flex items-center gap-4 sm:gap-8">
|
<div className="flex items-center gap-4 sm:gap-8">
|
||||||
<span className="hero-heading font-black leading-none" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
|
<span className="hero-heading font-black leading-none" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
|
||||||
{String(index + 1).padStart(2, '0')}
|
{String(index + 1).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
<div className="text-[#D7E2EA]">
|
<div className="min-w-0 text-[#D7E2EA]">
|
||||||
<p className="text-xs font-light uppercase tracking-widest opacity-70 sm:text-sm">{project.category}</p>
|
<p className="text-xs font-light uppercase tracking-widest opacity-70 sm:text-sm">{project.category}</p>
|
||||||
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{project.name}</h3>
|
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{project.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<LiveProjectButton />
|
<p className="max-w-xl px-1 font-light leading-relaxed text-[#D7E2EA]/70" style={{ fontSize: 'clamp(0.85rem, 1.5vw, 1.15rem)' }}>
|
||||||
|
{project.body}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 sm:gap-4">
|
<div className="flex gap-3 sm:gap-4">
|
||||||
<div className="flex w-[40%] flex-col gap-3 sm:gap-4">
|
<div className="flex w-[40%] flex-col gap-3 sm:gap-4">
|
||||||
<img src={project.col1[0]} alt="" loading="lazy" className={`w-full object-cover ${radius}`} style={{ height: 'clamp(130px, 16vw, 230px)' }} />
|
<img src={project.images[0]} alt="" loading="lazy" className={`w-full object-cover ${radius}`} style={{ height: 'clamp(130px, 16vw, 230px)', objectPosition: project.position[0] }} />
|
||||||
<img src={project.col1[1]} alt="" loading="lazy" className={`w-full object-cover ${radius}`} style={{ height: 'clamp(160px, 22vw, 340px)' }} />
|
<img src={project.images[1]} alt="" loading="lazy" className={`w-full object-cover ${radius}`} style={{ height: 'clamp(160px, 22vw, 340px)', objectPosition: project.position[1] }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[60%]">
|
<div className={`flex w-[60%] flex-col justify-center gap-3 border border-[#D7E2EA]/25 p-4 sm:gap-5 sm:p-8 md:p-12 ${radius}`} style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)' }}>
|
||||||
<img src={project.col2} alt="" loading="lazy" className={`h-full w-full object-cover ${radius}`} />
|
<span className="text-xs font-medium uppercase tracking-widest text-[#D7E2EA]/60 sm:text-sm">Delivered</span>
|
||||||
|
<ul className="flex flex-col gap-2 sm:gap-4">
|
||||||
|
{project.delivered.map((d) => (
|
||||||
|
<li key={d} className="flex gap-2 font-light leading-snug text-[#D7E2EA] sm:gap-3" style={{ fontSize: 'clamp(0.8rem, 1.6vw, 1.35rem)' }}>
|
||||||
|
<Check className="mt-0.5 h-4 w-4 shrink-0 sm:mt-1 sm:h-5 sm:w-5" />
|
||||||
|
<span>{d}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -51,7 +61,7 @@ export default function ProjectsSection() {
|
|||||||
<section id="projects" className="relative z-10 -mt-10 rounded-t-[40px] bg-[#0C0C0C] px-5 py-20 sm:-mt-12 sm:rounded-t-[50px] sm:px-8 sm:py-24 md:-mt-14 md:rounded-t-[60px] md:px-10 md:py-32">
|
<section id="projects" className="relative z-10 -mt-10 rounded-t-[40px] bg-[#0C0C0C] px-5 py-20 sm:-mt-12 sm:rounded-t-[50px] sm:px-8 sm:py-24 md:-mt-14 md:rounded-t-[60px] md:px-10 md:py-32">
|
||||||
<FadeIn>
|
<FadeIn>
|
||||||
<h2 className="hero-heading mb-16 text-center font-black uppercase leading-none tracking-tight sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
<h2 className="hero-heading mb-16 text-center font-black uppercase leading-none tracking-tight sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||||
Project
|
Projects
|
||||||
</h2>
|
</h2>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
{projects.map((p, i) => (
|
{projects.map((p, i) => (
|
||||||
+7
-7
@@ -1,23 +1,23 @@
|
|||||||
import FadeIn from '../components/FadeIn';
|
import FadeIn from '../components/FadeIn';
|
||||||
import { services } from '../data/content';
|
import { expertise } from '../data/content';
|
||||||
|
|
||||||
export default function ServicesSection() {
|
export default function ServicesSection() {
|
||||||
return (
|
return (
|
||||||
<section id="services" className="rounded-t-[40px] bg-white px-5 py-20 sm:rounded-t-[50px] sm:px-8 sm:py-24 md:rounded-t-[60px] md:px-10 md:py-32">
|
<section id="skills" className="rounded-t-[40px] bg-white px-5 pt-20 sm:rounded-t-[50px] sm:px-8 sm:pt-24 md:rounded-t-[60px] md:px-10 md:pt-32">
|
||||||
<h2 className="mb-16 text-center font-black uppercase text-[#0C0C0C] sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
<h2 className="mb-16 text-center font-black uppercase text-[#0C0C0C] sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||||
Services
|
Capability
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mx-auto max-w-5xl">
|
<div className="mx-auto max-w-5xl">
|
||||||
{services.map((s, i) => (
|
{expertise.map((s, i) => (
|
||||||
<FadeIn key={s.name} delay={i * 0.1}>
|
<FadeIn key={s.name} delay={(i % 4) * 0.1}>
|
||||||
<div
|
<div
|
||||||
className="flex items-start gap-4 py-8 sm:gap-8 sm:py-10 md:gap-12 md:py-12"
|
className="flex items-start gap-4 py-8 sm:gap-8 sm:py-10 md:gap-12 md:py-12"
|
||||||
style={{ borderTop: i === 0 ? undefined : '1px solid rgba(12, 12, 12, 0.15)' }}
|
style={{ borderTop: i === 0 ? undefined : '1px solid rgba(12, 12, 12, 0.15)' }}
|
||||||
>
|
>
|
||||||
<span className="font-black leading-none text-[#0C0C0C]" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
|
<span className="min-w-[1.6em] font-black leading-none text-[#0C0C0C]" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
|
||||||
{String(i + 1).padStart(2, '0')}
|
{String(i + 1).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-3 text-[#0C0C0C]">
|
<div className="flex min-w-0 flex-col gap-3 text-[#0C0C0C]">
|
||||||
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{s.name}</h3>
|
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{s.name}</h3>
|
||||||
<p className="max-w-2xl font-light leading-relaxed opacity-60" style={{ fontSize: 'clamp(0.85rem, 1.6vw, 1.25rem)' }}>
|
<p className="max-w-2xl font-light leading-relaxed opacity-60" style={{ fontSize: 'clamp(0.85rem, 1.6vw, 1.25rem)' }}>
|
||||||
{s.description}
|
{s.description}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// INLINE=1 bakes every image and font into the JS/CSS (used for the one-file cloud preview).
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: './',
|
||||||
|
build: { assetsInlineLimit: process.env.INLINE ? 100_000_000 : 4096 },
|
||||||
|
});
|
||||||
Reference in new issue
Block a user