Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da59b52738 | ||
|
|
ee2c54d664 | ||
|
|
60a55107f8 | ||
|
|
9bddc5722c | ||
|
|
7be9a85754 | ||
|
|
d69ec605dc | ||
|
|
0b42fdeae1 |
No files matched your search
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
name: remotion-video
|
||||||
|
description: Create videos from React code with Remotion. Use when the user asks to make, animate, preview, or render a video, motion graphic, intro, caption, or social clip using code.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Remotion Video
|
||||||
|
|
||||||
|
Remotion makes videos out of React components. Each frame is a web page. Render them in order and you get an MP4.
|
||||||
|
|
||||||
|
## Core ideas
|
||||||
|
- **Composition**: one video. Has `width`, `height`, `fps`, `durationInFrames`.
|
||||||
|
- **Frame**: `useCurrentFrame()` returns the current frame number. All animation is a function of the frame.
|
||||||
|
- **`interpolate`**: maps a frame range to a value range (fade, slide).
|
||||||
|
- **`spring`**: natural bouncy motion.
|
||||||
|
- **`Sequence`**: shows children only during a frame range.
|
||||||
|
- **`AbsoluteFill`**: full-screen layer; stack them for backgrounds.
|
||||||
|
- **`Audio` / `OffthreadVideo` / `Img`**: media. Put files in `public/` and load with `staticFile("name.mp3")`.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
1. **Plan first.** Ask or decide: length (seconds), size (1920x1080, or 1080x1920 for vertical), fps (30), and the scenes. Keep scenes to a short list.
|
||||||
|
2. **Set up** (skip if the project exists):
|
||||||
|
```
|
||||||
|
npx create-video@latest
|
||||||
|
cd <project> && npm install
|
||||||
|
```
|
||||||
|
3. **Write** compositions in `src/`, register them in `src/Root.tsx`.
|
||||||
|
4. **Preview**: `npm run dev` (opens Remotion Studio).
|
||||||
|
5. **Render**: `npx remotion render <CompositionId> out/video.mp4`
|
||||||
|
Still frame: `npx remotion still <CompositionId> out/frame.png --frame=30`
|
||||||
|
6. **Verify**: render one still first, check it looks right, then render the full video.
|
||||||
|
|
||||||
|
## Starter example
|
||||||
|
`src/Root.tsx`
|
||||||
|
```tsx
|
||||||
|
import { Composition } from "remotion";
|
||||||
|
import { Hello } from "./Hello";
|
||||||
|
|
||||||
|
export const RemotionRoot = () => (
|
||||||
|
<Composition
|
||||||
|
id="Hello"
|
||||||
|
component={Hello}
|
||||||
|
durationInFrames={150}
|
||||||
|
fps={30}
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
defaultProps={{ title: "Hello, world" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/Hello.tsx`
|
||||||
|
```tsx
|
||||||
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
|
||||||
|
export const Hello = ({ title }: { title: string }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" });
|
||||||
|
const scale = spring({ frame, fps, config: { damping: 12 } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: "#0b1020", justifyContent: "center", alignItems: "center" }}>
|
||||||
|
<h1 style={{ color: "white", fontSize: 120, opacity, transform: `scale(${scale})` }}>{title}</h1>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules of thumb
|
||||||
|
- Never animate with CSS transitions, `setTimeout`, or CSS animations. They don't render per frame. Drive everything from `useCurrentFrame()`.
|
||||||
|
- Always pass `extrapolateRight: "clamp"` to `interpolate` unless you want values to keep growing.
|
||||||
|
- Seconds to frames: `seconds * fps`.
|
||||||
|
- Use `<Sequence from={..} durationInFrames={..}>` to order scenes; `<Series>` to chain them.
|
||||||
|
- Load fonts with `@remotion/google-fonts` so they are ready before rendering.
|
||||||
|
- Keep props in `defaultProps` so the user can edit text in the Studio.
|
||||||
|
- Rendering needs Chromium and ffmpeg (Remotion installs/handles these); first render can be slow.
|
||||||
|
|
||||||
|
## Fonts
|
||||||
|
Google Fonts: `npm i @remotion/google-fonts`, then
|
||||||
|
```tsx
|
||||||
|
import { loadFont } from "@remotion/google-fonts/Inter";
|
||||||
|
const { fontFamily } = loadFont(); // use as style={{ fontFamily }}
|
||||||
|
```
|
||||||
|
It downloads the font while rendering, so the machine needs internet access to fonts.googleapis.com. If the render fails with "Failed to load font", the network is blocking it: use a local font file in `public/` with `@remotion/fonts` (`loadFont({ family, url: staticFile("x.woff2") })`) or a system font. (Not verified in the sandbox where this skill was tested, which had no access to Google.)
|
||||||
|
|
||||||
|
## Common problems
|
||||||
|
- **Can't download the browser (no internet / locked-down machine)**: point Remotion at an installed Chromium:
|
||||||
|
`npx remotion render <Id> out/video.mp4 --browser-executable=/path/to/chromium --chrome-mode=chrome-for-testing`
|
||||||
|
- **Blank/white frames**: an asset path is wrong; use `staticFile()`.
|
||||||
|
- **Flicker**: an image or font not loaded; use `<Img>` and `@remotion/google-fonts`.
|
||||||
|
- **Slow render**: lower resolution/fps while testing, or use `--concurrency`.
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
Copy-paste starting points. Read the one that fits:
|
||||||
|
- `templates/captions.md` - words highlighted in time with speech
|
||||||
|
- `templates/charts.md` - animated bar chart
|
||||||
|
- `templates/intro-outro.md` - title card + chaining scenes with `Series`
|
||||||
|
- `templates/audio-voiceover.md` - background music, voiceover, auto video length
|
||||||
|
- `templates/vertical.md` - 1080x1920 clips for Reels/TikTok/Shorts
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
https://www.remotion.dev/docs
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Template: Audio, music and voiceover
|
||||||
|
|
||||||
|
Put sound files in `public/` (mp3 or wav). Load them with `staticFile()`.
|
||||||
|
|
||||||
|
`src/Narrated.tsx`
|
||||||
|
```tsx
|
||||||
|
import { AbsoluteFill, Audio, Sequence, interpolate, staticFile, useVideoConfig } from "remotion";
|
||||||
|
import { getAudioDurationInSeconds } from "@remotion/media-utils";
|
||||||
|
import type { CalculateMetadataFunction } from "remotion";
|
||||||
|
|
||||||
|
type Props = { title: string; voice: string; music: string };
|
||||||
|
|
||||||
|
// Make the video exactly as long as the voiceover (+1 second of padding).
|
||||||
|
export const calcNarratedMetadata: CalculateMetadataFunction<Props> = async ({ props }) => {
|
||||||
|
const seconds = await getAudioDurationInSeconds(staticFile(props.voice));
|
||||||
|
return { durationInFrames: Math.ceil((seconds + 1) * 30) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Narrated = ({ title, voice, music }: Props) => {
|
||||||
|
const { fps, durationInFrames } = useVideoConfig();
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ background: "#0b1020", justifyContent: "center", alignItems: "center" }}>
|
||||||
|
<h1 style={{ color: "white", fontSize: 110 }}>{title}</h1>
|
||||||
|
|
||||||
|
{/* Background music: quiet, fades out over the last second. */}
|
||||||
|
<Audio
|
||||||
|
src={staticFile(music)}
|
||||||
|
volume={(f) => interpolate(f, [durationInFrames - fps, durationInFrames], [0.15, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Voiceover starts half a second in, full volume. */}
|
||||||
|
<Sequence from={Math.round(fps * 0.5)}>
|
||||||
|
<Audio src={staticFile(voice)} volume={1} />
|
||||||
|
</Sequence>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Register it (note `calculateMetadata`):
|
||||||
|
```tsx
|
||||||
|
<Composition
|
||||||
|
id="Narrated"
|
||||||
|
component={Narrated}
|
||||||
|
calculateMetadata={calcNarratedMetadata}
|
||||||
|
durationInFrames={300} // placeholder, replaced by calculateMetadata
|
||||||
|
fps={30} width={1920} height={1080}
|
||||||
|
defaultProps={{ title: "My story", voice: "voice.mp3", music: "music.mp3" }}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Install: `npm i @remotion/media-utils`
|
||||||
|
|
||||||
|
Getting a voiceover
|
||||||
|
- Record yourself, or generate speech with a text-to-speech service, and save as `public/voice.mp3`. Never commit API keys; keep them in environment variables.
|
||||||
|
- Pair with `templates/captions.md`: feed it word timings from a transcription of the same file.
|
||||||
|
|
||||||
|
Rules
|
||||||
|
- Music volume 0.1-0.2 so the voice stays clear.
|
||||||
|
- `<Sequence from={...}>` delays when a sound starts.
|
||||||
|
- Audio is only in the final render (mp4). Stills (`remotion still`) have no sound.
|
||||||
|
- Check the sound made it in: `ffprobe out/video.mp4` should list an `Audio:` stream.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Template: Captions
|
||||||
|
|
||||||
|
Words appear in time with speech. Give each word a start time in seconds.
|
||||||
|
|
||||||
|
`src/Captions.tsx`
|
||||||
|
```tsx
|
||||||
|
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
|
||||||
|
type Word = { text: string; start: number; end: number }; // seconds
|
||||||
|
|
||||||
|
export const Captions = ({ words, background = "#111" }: { words: Word[]; background?: string }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
const t = frame / fps;
|
||||||
|
|
||||||
|
// Show a window of up to 5 words around the current one.
|
||||||
|
const current = words.findIndex((w) => t >= w.start && t < w.end);
|
||||||
|
const i = current === -1 ? 0 : current;
|
||||||
|
const visible = words.slice(Math.floor(i / 5) * 5, Math.floor(i / 5) * 5 + 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: background, justifyContent: "flex-end", alignItems: "center", paddingBottom: 200 }}>
|
||||||
|
<div style={{ fontSize: 72, fontWeight: 800, color: "white", textShadow: "0 4px 12px black", textAlign: "center", maxWidth: "80%" }}>
|
||||||
|
{visible.map((w, k) => (
|
||||||
|
<span key={k} style={{ color: t >= w.start && t < w.end ? "#ffd400" : "white", marginRight: 16 }}>
|
||||||
|
{w.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Tips
|
||||||
|
- Get word times from a transcription tool (e.g. `@remotion/captions` or Whisper) and save them as JSON in `public/`.
|
||||||
|
- `background` is the backdrop when there is no video. Pass `"transparent"` when layering over a video.
|
||||||
|
- Layer it over the video: `<AbsoluteFill><OffthreadVideo src={staticFile("clip.mp4")} /><Captions words={words} background="transparent" /></AbsoluteFill>`.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Template: Animated bar chart
|
||||||
|
|
||||||
|
Bars grow one after another.
|
||||||
|
|
||||||
|
`src/BarChart.tsx`
|
||||||
|
```tsx
|
||||||
|
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
|
||||||
|
type Bar = { label: string; value: number };
|
||||||
|
|
||||||
|
export const BarChart = ({ data, color = "#4f8cff" }: { data: Bar[]; color?: string }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
const max = Math.max(...data.map((d) => d.value));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: "#0b1020", padding: 120, justifyContent: "flex-end" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-end", gap: 40, height: 700 }}>
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const grow = spring({ frame: frame - i * 6, fps, config: { damping: 15 } });
|
||||||
|
return (
|
||||||
|
<div key={d.label} style={{ flex: 1, textAlign: "center", color: "white", fontSize: 36 }}>
|
||||||
|
<div style={{ height: (d.value / max) * 600 * grow, background: color, borderRadius: 8 }} />
|
||||||
|
<div style={{ marginTop: 16 }}>{d.label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Tips
|
||||||
|
- Delay each bar with `frame - i * 6` (6 frames apart).
|
||||||
|
- For lines, animate `strokeDashoffset` on an SVG path using `interpolate`.
|
||||||
|
- Keep numbers readable: show the value as text, rounded.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Template: Title intro / outro card
|
||||||
|
|
||||||
|
A short card (2-3 seconds) with a title and subtitle. Use at the start or end.
|
||||||
|
|
||||||
|
`src/TitleCard.tsx`
|
||||||
|
```tsx
|
||||||
|
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
|
||||||
|
export const TitleCard = ({ title, subtitle }: { title: string; subtitle?: string }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps, durationInFrames } = useVideoConfig();
|
||||||
|
const rise = spring({ frame, fps, config: { damping: 14 } });
|
||||||
|
const fadeOut = interpolate(frame, [durationInFrames - 15, durationInFrames], [1, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ background: "linear-gradient(135deg,#1b2a6b,#0b1020)", justifyContent: "center", alignItems: "center", opacity: fadeOut }}>
|
||||||
|
<h1 style={{ color: "white", fontSize: 130, margin: 0, transform: `translateY(${(1 - rise) * 60}px)`, opacity: rise }}>{title}</h1>
|
||||||
|
{subtitle && <p style={{ color: "#9fb3ff", fontSize: 48, opacity: rise }}>{subtitle}</p>}
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Chain with the main video using `<Series>`:
|
||||||
|
```tsx
|
||||||
|
<Series>
|
||||||
|
<Series.Sequence durationInFrames={90}><TitleCard title="My Video" /></Series.Sequence>
|
||||||
|
<Series.Sequence durationInFrames={600}><MainScene /></Series.Sequence>
|
||||||
|
<Series.Sequence durationInFrames={90}><TitleCard title="Thanks for watching" /></Series.Sequence>
|
||||||
|
</Series>
|
||||||
|
```
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Template: Vertical clips (Reels / TikTok / Shorts)
|
||||||
|
|
||||||
|
Same code, different size. Register a vertical composition:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Composition
|
||||||
|
id="Vertical"
|
||||||
|
component={MyScene}
|
||||||
|
width={1080}
|
||||||
|
height={1920}
|
||||||
|
fps={30}
|
||||||
|
durationInFrames={30 * 30} // 30 seconds
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Render: `npx remotion render Vertical out/vertical.mp4`
|
||||||
|
|
||||||
|
Rules
|
||||||
|
- Keep text inside the **safe zone**: about 150px from the top, 350px from the bottom (apps cover those parts with buttons and captions), and 60px from the sides.
|
||||||
|
- Use big text (60px+). Phone screens are small.
|
||||||
|
- Keep clips under 60 seconds (Shorts limit); 15-30 seconds does best.
|
||||||
|
- Use `useVideoConfig()` for width/height instead of hard-coding, so the same scene works wide and tall.
|
||||||
|
- Put captions in the lower-middle, above the bottom safe zone (see captions.md).
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
---
|
||||||
|
name: website-builder-setup
|
||||||
|
description: "Install the full AI website builder stack — UI/UX Pro Max, Framer Motion animations, and 21st.dev components. One skill, three tools, zero coding experience needed."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Website Builder Setup
|
||||||
|
|
||||||
|
This skill walks you through installing everything you need to build professional, animated websites with Claude Code. No coding experience required.
|
||||||
|
|
||||||
|
## What Gets Installed
|
||||||
|
|
||||||
|
| Tool | What it does |
|
||||||
|
|------|-------------|
|
||||||
|
| **UI/UX Pro Max** | Gives Claude access to 50+ design styles, 161 color palettes, 57 font pairings. Your sites look designed, not generated. |
|
||||||
|
| **Framer Motion** | Adds smooth animations — page transitions, hover effects, scroll reveals. Makes sites feel alive. |
|
||||||
|
| **21st.dev Magic** | A library of 100+ polished React components Claude can pull from. Production-quality building blocks. |
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
When this skill is triggered, walk the user through each step one at a time. Be encouraging and clear — assume they have zero coding experience. If any step fails, don't stop. Acknowledge it, give them the manual command, and keep moving.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 1: Check Prerequisites
|
||||||
|
|
||||||
|
> Before we start, let me make sure you have what we need.
|
||||||
|
|
||||||
|
Run this silently:
|
||||||
|
```bash
|
||||||
|
node --version 2>&1 && npm --version 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
- If Node.js is installed → say "You're good — Node.js is installed. Let's go."
|
||||||
|
- If NOT installed → say:
|
||||||
|
|
||||||
|
> You need Node.js first. Go to https://nodejs.org and download the LTS version. Install it, restart your terminal, then come back and run `/website-builder-setup` again. Takes 2 minutes.
|
||||||
|
|
||||||
|
Stop here if Node is missing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Install UI/UX Pro Max
|
||||||
|
|
||||||
|
> **Step 1 of 3: UI/UX Pro Max**
|
||||||
|
>
|
||||||
|
> This gives me a massive design library — 50+ styles, 161 color palettes, 57 font pairings. When you ask me to build a website, I'm pulling from a real design system instead of guessing. This is why the output won't look like generic AI slop.
|
||||||
|
>
|
||||||
|
> Installing now...
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npm install -g uipro-cli 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
```bash
|
||||||
|
uipro init --ai claude 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
- On success → "UI/UX Pro Max is installed. Your design stack is ready."
|
||||||
|
- On failure → "Hit a snag. You can try manually later: `npm install -g uipro-cli && uipro init --ai claude`. Let's keep going."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: Install Framer Motion
|
||||||
|
|
||||||
|
> **Step 2 of 3: Framer Motion**
|
||||||
|
>
|
||||||
|
> This teaches me how to add real animations to your websites — smooth page transitions, hover effects, scroll-triggered reveals. The stuff that makes a $500 site look like a $10,000 site.
|
||||||
|
>
|
||||||
|
> Installing now...
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npm install framer-motion 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
- On success → "Framer Motion is installed. Your sites will have real animations now."
|
||||||
|
- On failure → "Hit a snag. You can try manually later: `npm install framer-motion`. Moving on."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: Set Up 21st.dev Magic
|
||||||
|
|
||||||
|
> **Step 3 of 3: 21st.dev Components**
|
||||||
|
>
|
||||||
|
> This connects me to a library of 100+ beautifully designed React components. Instead of building everything from scratch, I pull from production-quality building blocks — buttons, navbars, hero sections, cards, footers — all pre-designed and ready to use.
|
||||||
|
>
|
||||||
|
> This one needs a free API key. Here's how to get it:
|
||||||
|
>
|
||||||
|
> 1. Go to **https://21st.dev/magic/console**
|
||||||
|
> 2. Sign up or log in (it's free)
|
||||||
|
> 3. Copy your API key
|
||||||
|
> 4. Paste it here when I ask for it
|
||||||
|
|
||||||
|
Wait for the user to provide their API key.
|
||||||
|
|
||||||
|
Once they provide it, add the MCP server to their Claude Code config:
|
||||||
|
|
||||||
|
Read `~/.claude.json`, find the `mcpServers` object, and add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"21st-dev-magic": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@21st-dev/magic@latest"],
|
||||||
|
"env": {
|
||||||
|
"API_KEY": "THEIR_KEY_HERE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After writing the config:
|
||||||
|
|
||||||
|
> 21st.dev Magic is connected. You'll need to restart Claude Code for this one to kick in — just close and reopen your terminal after we're done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5: Done
|
||||||
|
|
||||||
|
> **You're all set.** Here's what you just installed:
|
||||||
|
>
|
||||||
|
> - **UI/UX Pro Max** — 50+ styles, 161 palettes, 57 font pairings
|
||||||
|
> - **Framer Motion** — smooth, professional animations
|
||||||
|
> - **21st.dev Magic** — 100+ production-ready components
|
||||||
|
>
|
||||||
|
> **To build your first website, just tell me:**
|
||||||
|
> - What your business does
|
||||||
|
> - Who it's for
|
||||||
|
> - What vibe you want (dark, minimal, bold, playful, etc.)
|
||||||
|
>
|
||||||
|
> I'll handle the rest. Try something like:
|
||||||
|
>
|
||||||
|
> *"Build me a landing page for my consulting business targeting small business owners. Dark theme, modern, with animations."*
|
||||||
|
>
|
||||||
|
> **Important:** Restart Claude Code first so 21st.dev loads in. Then let's build something.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- Walk through each step ONE AT A TIME
|
||||||
|
- Never dump all instructions at once
|
||||||
|
- If any install fails, don't stop — acknowledge, give manual command, keep moving
|
||||||
|
- Be encouraging and casual throughout
|
||||||
|
- Assume zero coding experience
|
||||||
|
- After everything is installed, prompt them to build their first site
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# LookAtU: Product Plan (PRD)
|
||||||
|
|
||||||
|
## What is it? (the 4th-grade version)
|
||||||
|
LookAtU is a helper for parents. The parent has an app. The child has an app. The parent can see where the child is, see what the child does on the phone, choose which apps the child can use, and check calls and messages. If the child is in danger, the child taps a big **panic button** and the parent gets an alert right away.
|
||||||
|
|
||||||
|
## Who is it for?
|
||||||
|
- **Parents** of kids who have their own phone.
|
||||||
|
- **Kids**, who need a simple way to ask for help.
|
||||||
|
|
||||||
|
## Core features
|
||||||
|
1. **Live location**: the parent sees where the child is, any time.
|
||||||
|
2. **Activity monitor**: which apps are used and for how long.
|
||||||
|
3. **App control**: the parent can allow, block, or limit apps, and approve new installs.
|
||||||
|
4. **Calls and messages**: the parent can check call history and messages.
|
||||||
|
5. **Panic button**: the child taps once. The parent gets an alert with the child's location.
|
||||||
|
|
||||||
|
## Trust and safety rules (built in from day one)
|
||||||
|
- The child **knows** the app is on their phone. No hidden spying. This is the honest way to do it, and app stores require it.
|
||||||
|
- The parent signs in with a real account and a strong password.
|
||||||
|
- Data is private and encrypted. Only the parent sees it.
|
||||||
|
- Rules about monitoring messages differ by country and by the child's age. We must check the law for each country before launch.
|
||||||
|
|
||||||
|
## Phased roadmap
|
||||||
|
|
||||||
|
### Phase 1: Quick win (TODAY)
|
||||||
|
A good-looking website (one page) that explains LookAtU and collects sign-ups.
|
||||||
|
- Hero, 5 feature cards, "how it works", trust section, FAQ
|
||||||
|
- Waitlist form that checks the email is real-looking
|
||||||
|
- Works on phones and computers
|
||||||
|
- **Done when:** page loads with no errors and the form and buttons work.
|
||||||
|
|
||||||
|
### Phase 2: Make it real
|
||||||
|
- Connect the waitlist form to a real service so emails are saved.
|
||||||
|
- Add animations (Framer Motion) and better pictures.
|
||||||
|
- Add privacy and terms pages.
|
||||||
|
|
||||||
|
### Phase 3: Parent accounts
|
||||||
|
- Sign up and log in.
|
||||||
|
- A simple parent dashboard (website).
|
||||||
|
- Link a parent to a child.
|
||||||
|
|
||||||
|
### Phase 4: Phone apps
|
||||||
|
- Child app and parent app (Android first, then iPhone).
|
||||||
|
- Live location and the panic button with alerts (push notifications and SMS).
|
||||||
|
|
||||||
|
### Phase 5: Monitoring and control
|
||||||
|
- App usage, app blocking and install approval.
|
||||||
|
- Call and message history (after legal review).
|
||||||
|
|
||||||
|
## How we test at every phase
|
||||||
|
1. Page loads with **no errors**.
|
||||||
|
2. Buttons and forms **work**.
|
||||||
|
3. Real services (database, email, maps, alerts) **work**.
|
||||||
|
4. **Security check**: passwords, private data, no secrets in code.
|
||||||
|
|
||||||
|
## Open questions for the owner
|
||||||
|
- Which countries first?
|
||||||
|
- Free or paid? (price)
|
||||||
|
- Android first, or iPhone first?
|
||||||
|
- Final name and logo?
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# PRD: Keith Rana Portfolio (built from the "Jack 3D Creator" template)
|
|
||||||
|
|
||||||
## The restaurant analogy
|
|
||||||
| Stage | Restaurant | This project |
|
|
||||||
|---|---|---|
|
|
||||||
| 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 3 | Full restaurant: front of house + back of house | Real contact form, email, project pages, CMS, analytics |
|
|
||||||
|
|
||||||
Front of house = what visitors see. Back of house = forms, email, storage, security.
|
|
||||||
|
|
||||||
## Core features
|
|
||||||
1. Hero: giant "Hi, i'm jack" title, nav, magnetic portrait, Contact button
|
|
||||||
2. Marquee: two rows of GIFs sliding left/right as you scroll
|
|
||||||
3. About: floating 3D icons + words that light up as you scroll
|
|
||||||
4. Services: white panel, 5 numbered services
|
|
||||||
5. Projects: 3 cards that stack and shrink as you scroll
|
|
||||||
6. Reusable parts: ContactButton, LiveProjectButton, FadeIn, Magnet, AnimatedText
|
|
||||||
|
|
||||||
## Phased roadmap
|
|
||||||
**Phase 1 — Food stand (today).** Static site, all 5 sections, all animations, responsive.
|
|
||||||
Done when: builds with no errors; page loads with no console errors; every button/link works; all image URLs load.
|
|
||||||
|
|
||||||
**Phase 2 — Small restaurant.** Nav links smooth-scroll to sections, Contact Me opens a mailto, content moved into editable data files, reduced-motion support, alt text, SEO tags, image fallbacks.
|
|
||||||
|
|
||||||
**Phase 3 — Full restaurant.** Contact form with a backend (server-side validation, spam protection, rate limits), project detail pages, CMS, analytics, hosting.
|
|
||||||
|
|
||||||
## Test ladder (each step must pass before the next)
|
|
||||||
1. Page loads with no errors
|
|
||||||
2. Buttons / links / forms work
|
|
||||||
3. Real external APIs/assets work (images load)
|
|
||||||
4. Security check (no secrets in code, safe external links, dependency audit)
|
|
||||||
|
|
||||||
## 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 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`).
|
|
||||||
- Each project card has its own illustrative diagram (drawn from the deliverables in Keith's portfolio text; swap for real screenshots when available).
|
|
||||||
- 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.
|
|
||||||
@@ -1,13 +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="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,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "keith-portfolio",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc --noEmit && vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@fontsource/kanit": "^5.3.0",
|
|
||||||
"framer-motion": "^12.38.0",
|
|
||||||
"lucide-react": "^0.344.0",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^18.3.5",
|
|
||||||
"@types/react-dom": "^18.3.0",
|
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
|
||||||
"autoprefixer": "^10.4.18",
|
|
||||||
"postcss": "^8.4.35",
|
|
||||||
"tailwindcss": "^3.4.1",
|
|
||||||
"typescript": "^5.5.3",
|
|
||||||
"vite": "^5.4.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
|
||||||
};
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// 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' : ''}`);
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import HeroSection from './sections/HeroSection';
|
|
||||||
import MarqueeSection from './sections/MarqueeSection';
|
|
||||||
import AboutSection from './sections/AboutSection';
|
|
||||||
import ServicesSection from './sections/ServicesSection';
|
|
||||||
import ExperienceSection from './sections/ExperienceSection';
|
|
||||||
import ProjectsSection from './sections/ProjectsSection';
|
|
||||||
import CredentialsSection from './sections/CredentialsSection';
|
|
||||||
import ContactSection from './sections/ContactSection';
|
|
||||||
import { ContactProvider } from './components/ContactDialog';
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return (
|
|
||||||
<ContactProvider>
|
|
||||||
<main style={{ background: '#0C0C0C', overflowX: 'clip' }}>
|
|
||||||
<HeroSection />
|
|
||||||
<MarqueeSection />
|
|
||||||
<AboutSection />
|
|
||||||
<ServicesSection />
|
|
||||||
<ExperienceSection />
|
|
||||||
<ProjectsSection />
|
|
||||||
<CredentialsSection />
|
|
||||||
<ContactSection />
|
|
||||||
</main>
|
|
||||||
</ContactProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 641 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 277 KiB |
@@ -1,30 +0,0 @@
|
|||||||
import { useRef } from 'react';
|
|
||||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
|
||||||
import type { MotionValue } from 'framer-motion';
|
|
||||||
|
|
||||||
function Char({ char, index, total, progress }: { char: string; index: number; total: number; progress: MotionValue<number> }) {
|
|
||||||
const start = index / total;
|
|
||||||
const end = start + 1 / total;
|
|
||||||
const opacity = useTransform(progress, [start, end], [0.2, 1]);
|
|
||||||
return (
|
|
||||||
<span className="relative">
|
|
||||||
<span className="invisible">{char}</span>
|
|
||||||
<motion.span className="absolute left-0 top-0" style={{ opacity }}>
|
|
||||||
{char}
|
|
||||||
</motion.span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AnimatedText({ text, className, style }: { text: string; className?: string; style?: React.CSSProperties }) {
|
|
||||||
const ref = useRef<HTMLParagraphElement>(null);
|
|
||||||
const { scrollYProgress } = useScroll({ target: ref, offset: ['start 0.8', 'end 0.2'] });
|
|
||||||
const chars = Array.from(text);
|
|
||||||
return (
|
|
||||||
<p ref={ref} className={className} style={style} aria-label={text}>
|
|
||||||
{chars.map((c, i) =>
|
|
||||||
c === ' ' ? ' ' : <Char key={i} char={c} index={i} total={chars.length} progress={scrollYProgress} />
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useOpenContact } from './ContactDialog';
|
|
||||||
|
|
||||||
export default function ContactButton() {
|
|
||||||
const openContact = useOpenContact();
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openContact}
|
|
||||||
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={{
|
|
||||||
background: 'linear-gradient(123deg, #18011F 7%, #B600A8 37%, #7621B0 72%, #BE4C00 100%)',
|
|
||||||
boxShadow: '0px 4px 4px rgba(181, 1, 167, 0.25), 4px 4px 12px #7721B1 inset',
|
|
||||||
outline: '2px solid white',
|
|
||||||
outlineOffset: '-3px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Contact Me
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { Check, Copy, Mail, X } from 'lucide-react';
|
|
||||||
import { profile } from '../data/content';
|
|
||||||
|
|
||||||
const OpenContext = createContext<() => void>(() => {});
|
|
||||||
export const useOpenContact = () => useContext(OpenContext);
|
|
||||||
|
|
||||||
const subject = encodeURIComponent('Portfolio enquiry');
|
|
||||||
const to = encodeURIComponent(profile.email);
|
|
||||||
const options = [
|
|
||||||
{ label: 'Open my email app', href: `mailto:${profile.email}?subject=${subject}`, newTab: false },
|
|
||||||
{ label: 'Write in Gmail', href: `https://mail.google.com/mail/?view=cm&fs=1&to=${to}&su=${subject}`, newTab: true },
|
|
||||||
{ label: 'Write in Yahoo Mail', href: `https://compose.mail.yahoo.com/?to=${to}&subject=${subject}`, newTab: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const optionClass =
|
|
||||||
'flex items-center justify-between gap-3 rounded-full border-2 border-[#D7E2EA] px-6 py-3 text-sm font-medium uppercase tracking-widest text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10 sm:text-base';
|
|
||||||
|
|
||||||
export function ContactProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const closeRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const opener = useRef<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const show = useCallback(() => {
|
|
||||||
opener.current = document.activeElement as HTMLElement | null;
|
|
||||||
setCopied(false);
|
|
||||||
setOpen(true);
|
|
||||||
}, []);
|
|
||||||
const hide = useCallback(() => {
|
|
||||||
setOpen(false);
|
|
||||||
opener.current?.focus?.();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
closeRef.current?.focus();
|
|
||||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && hide();
|
|
||||||
document.addEventListener('keydown', onKey);
|
|
||||||
const prev = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', onKey);
|
|
||||||
document.body.style.overflow = prev;
|
|
||||||
};
|
|
||||||
}, [open, hide]);
|
|
||||||
|
|
||||||
const copy = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(profile.email);
|
|
||||||
setCopied(true);
|
|
||||||
} catch {
|
|
||||||
inputRef.current?.select();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OpenContext.Provider value={show}>
|
|
||||||
{children}
|
|
||||||
{open && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
|
||||||
onMouseDown={(e) => e.target === e.currentTarget && hide()}
|
|
||||||
>
|
|
||||||
<div role="dialog" aria-modal="true" aria-labelledby="contact-title" className="relative flex w-full max-w-md flex-col gap-5 rounded-[32px] border-2 border-[#D7E2EA] bg-[#0C0C0C] p-6 sm:p-8">
|
|
||||||
<button ref={closeRef} onClick={hide} aria-label="Close" className="absolute right-4 top-4 rounded-full p-2 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
|
||||||
<X size={22} />
|
|
||||||
</button>
|
|
||||||
<h2 id="contact-title" className="hero-heading pr-10 text-4xl font-black uppercase leading-none sm:text-5xl">Email Keith</h2>
|
|
||||||
<p className="font-light leading-relaxed text-[#D7E2EA]/80">Pick how you would like to write. Your message goes to the address below.</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
id="contact-email"
|
|
||||||
readOnly
|
|
||||||
value={profile.email}
|
|
||||||
aria-label="Email address"
|
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
|
||||||
className="min-w-0 flex-1 rounded-full border border-[#D7E2EA]/40 bg-transparent px-5 py-3 text-[#D7E2EA]"
|
|
||||||
/>
|
|
||||||
<button onClick={copy} className="flex shrink-0 items-center gap-2 rounded-full border border-[#D7E2EA]/40 px-4 py-3 text-sm text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
|
||||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
|
||||||
{copied ? 'Copied' : 'Copy'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{options.map((o) => (
|
|
||||||
<a key={o.label} href={o.href} className={optionClass} {...(o.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}>
|
|
||||||
{o.label}
|
|
||||||
<Mail size={18} />
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</OpenContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { motion } from 'framer-motion';
|
|
||||||
import type { MotionProps } from 'framer-motion';
|
|
||||||
import type { ComponentType, ElementType, ReactNode } from 'react';
|
|
||||||
|
|
||||||
type FadeInProps = {
|
|
||||||
children: ReactNode;
|
|
||||||
delay?: number;
|
|
||||||
duration?: number;
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
className?: string;
|
|
||||||
as?: ElementType;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cache = new Map<ElementType, ComponentType<MotionProps & { className?: string }>>();
|
|
||||||
const getMotion = (el: ElementType) => {
|
|
||||||
if (!cache.has(el)) cache.set(el, motion.create(el as 'div') as ComponentType<MotionProps & { className?: string }>);
|
|
||||||
return cache.get(el)!;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function FadeIn({
|
|
||||||
children,
|
|
||||||
delay = 0,
|
|
||||||
duration = 0.7,
|
|
||||||
x = 0,
|
|
||||||
y = 30,
|
|
||||||
className,
|
|
||||||
as = 'div',
|
|
||||||
}: FadeInProps) {
|
|
||||||
const Component = getMotion(as);
|
|
||||||
return (
|
|
||||||
<Component
|
|
||||||
className={className}
|
|
||||||
initial={{ opacity: 0, x, y }}
|
|
||||||
whileInView={{ opacity: 1, x: 0, y: 0 }}
|
|
||||||
viewport={{ once: true, margin: '50px', amount: 0 }}
|
|
||||||
transition={{ duration, delay, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Component>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
type MagnetProps = {
|
|
||||||
children: ReactNode;
|
|
||||||
padding?: number;
|
|
||||||
strength?: number;
|
|
||||||
activeTransition?: string;
|
|
||||||
inactiveTransition?: string;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Magnet({
|
|
||||||
children,
|
|
||||||
padding = 100,
|
|
||||||
strength = 2,
|
|
||||||
activeTransition = 'transform 0.3s ease-out',
|
|
||||||
inactiveTransition = 'transform 0.6s ease-in-out',
|
|
||||||
className,
|
|
||||||
}: MagnetProps) {
|
|
||||||
const [active, setActive] = useState(false);
|
|
||||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onMove = (e: MouseEvent) => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
const { left, top, width, height } = ref.current.getBoundingClientRect();
|
|
||||||
const cx = left + width / 2;
|
|
||||||
const cy = top + height / 2;
|
|
||||||
const dx = Math.abs(cx - e.clientX);
|
|
||||||
const dy = Math.abs(cy - e.clientY);
|
|
||||||
if (dx < width / 2 + padding && dy < height / 2 + padding) {
|
|
||||||
setActive(true);
|
|
||||||
setPos({ x: (e.clientX - cx) / strength, y: (e.clientY - cy) / strength });
|
|
||||||
} else {
|
|
||||||
setActive(false);
|
|
||||||
setPos({ x: 0, y: 0 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('mousemove', onMove);
|
|
||||||
return () => window.removeEventListener('mousemove', onMove);
|
|
||||||
}, [padding, strength]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className={className} style={{ position: 'relative', display: 'inline-block' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
|
|
||||||
transition: active ? activeTransition : inactiveTransition,
|
|
||||||
willChange: 'transform',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import type { CSSProperties, ReactNode } from 'react';
|
|
||||||
|
|
||||||
export type DiagramKind = 'network' | 'server' | 'sccm' | 'voice';
|
|
||||||
|
|
||||||
const INK = '#D7E2EA';
|
|
||||||
const TEAL = '#2BB3A6';
|
|
||||||
const MUTED = 'rgba(215,226,234,0.55)';
|
|
||||||
const FILL = '#14161a';
|
|
||||||
|
|
||||||
function Box({ x, y, w, h, label, sub, accent }: { x: number; y: number; w: number; h: number; label: string; sub?: string; accent?: boolean }) {
|
|
||||||
return (
|
|
||||||
<g>
|
|
||||||
<rect x={x} y={y} width={w} height={h} rx={14} fill={FILL} stroke={accent ? TEAL : INK} strokeWidth={accent ? 2.5 : 1.5} />
|
|
||||||
<text x={x + w / 2} y={y + h / 2 + (sub ? -4 : 8)} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>{label}</text>
|
|
||||||
{sub && <text x={x + w / 2} y={y + h / 2 + 22} textAnchor="middle" fontSize={18} fill={MUTED}>{sub}</text>}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Line({ x1, y1, x2, y2, dashed }: { x1: number; y1: number; x2: number; y2: number; dashed?: boolean }) {
|
|
||||||
return <line x1={x1} y1={y1} x2={x2} y2={y2} stroke={MUTED} strokeWidth={2} strokeDasharray={dashed ? '6 6' : undefined} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Arrow({ x1, y, x2, label }: { x1: number; y: number; x2: number; label?: string }) {
|
|
||||||
return (
|
|
||||||
<g>
|
|
||||||
<line x1={x1} y1={y} x2={x2 - 10} y2={y} stroke={TEAL} strokeWidth={3} />
|
|
||||||
<path d={`M${x2 - 14} ${y - 8} L${x2} ${y} L${x2 - 14} ${y + 8} Z`} fill={TEAL} />
|
|
||||||
{label && <text x={(x1 + x2) / 2} y={y - 14} textAnchor="middle" fontSize={20} fill={TEAL}>{label}</text>}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Dot({ cx, cy, r = 14 }: { cx: number; cy: number; r?: number }) {
|
|
||||||
return <circle cx={cx} cy={cy} r={r} fill={FILL} stroke={TEAL} strokeWidth={2.5} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Wifi({ cx, cy }: { cx: number; cy: number }) {
|
|
||||||
return (
|
|
||||||
<g fill="none" stroke={TEAL} strokeWidth={2.5} strokeLinecap="round">
|
|
||||||
<path d={`M${cx - 18} ${cy - 4} Q${cx} ${cy - 22} ${cx + 18} ${cy - 4}`} />
|
|
||||||
<path d={`M${cx - 10} ${cy + 4} Q${cx} ${cy - 6} ${cx + 10} ${cy + 4}`} />
|
|
||||||
<circle cx={cx} cy={cy + 12} r={2.5} fill={TEAL} />
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const diagrams: Record<DiagramKind, { label: string; body: ReactNode }> = {
|
|
||||||
network: {
|
|
||||||
label: 'Network topology: Fortinet firewall connects to Fortinet 48P switches, which connect to access points.',
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
<Box x={190} y={30} w={220} h={64} label="Fortinet firewall" accent />
|
|
||||||
<Line x1={300} y1={94} x2={300} y2={130} />
|
|
||||||
<Line x1={150} y1={130} x2={450} y2={130} />
|
|
||||||
<Line x1={150} y1={130} x2={150} y2={160} />
|
|
||||||
<Line x1={450} y1={130} x2={450} y2={160} />
|
|
||||||
<Box x={50} y={160} w={200} h={64} label="48P switch" sub="Fortinet" />
|
|
||||||
<Box x={350} y={160} w={200} h={64} label="48P switch" sub="Fortinet" />
|
|
||||||
{[90, 210, 390, 510].map((x, i) => (
|
|
||||||
<g key={x}>
|
|
||||||
<Line x1={i < 2 ? 150 : 450} y1={224} x2={x} y2={274} dashed />
|
|
||||||
<Dot cx={x} cy={296} r={22} />
|
|
||||||
<Wifi cx={x} cy={292} />
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
<text x={300} y={372} textAnchor="middle" fontSize={20} fill={MUTED}>Access point uplift</text>
|
|
||||||
<text x={300} y={400} textAnchor="middle" fontSize={20} fill={MUTED}>Network topology upgrade</text>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
label: 'Server upgrade: virtual machines and services move from the existing server to the upgraded server, with updated network settings.',
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
<Box x={20} y={70} w={190} h={200} label="" />
|
|
||||||
<text x={115} y={105} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>Existing server</text>
|
|
||||||
<Box x={40} y={130} w={150} h={44} label="VMs" />
|
|
||||||
<Box x={40} y={192} w={150} h={44} label="Services" />
|
|
||||||
<Arrow x1={222} y={170} x2={378} label="Data migrated" />
|
|
||||||
<Box x={390} y={70} w={190} h={200} label="" accent />
|
|
||||||
<text x={485} y={105} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>Upgraded server</text>
|
|
||||||
<Box x={410} y={130} w={150} h={44} label="VMs" />
|
|
||||||
<Box x={410} y={192} w={150} h={44} label="Services" />
|
|
||||||
<Line x1={485} y1={270} x2={485} y2={320} dashed />
|
|
||||||
<Box x={150} y={320} w={320} h={56} label="Updated network settings" />
|
|
||||||
<Line x1={470} y1={348} x2={485} y2={348} dashed />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
sccm: {
|
|
||||||
label: 'SCCM implementation: install and configure SCCM, prepare settings, then establish the task-sequence process for endpoints.',
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
<Box x={200} y={26} w={200} h={64} label="SCCM" accent />
|
|
||||||
<Line x1={300} y1={90} x2={300} y2={122} />
|
|
||||||
<Box x={40} y={122} w={150} h={92} label="Install" sub="& configure" />
|
|
||||||
<Box x={225} y={122} w={150} h={92} label="Prepare" sub="settings" />
|
|
||||||
<Box x={410} y={122} w={150} h={92} label="Task" sub="sequence" accent />
|
|
||||||
<Arrow x1={190} y={168} x2={225} />
|
|
||||||
<Arrow x1={375} y={168} x2={410} />
|
|
||||||
<Line x1={485} y1={214} x2={485} y2={262} dashed />
|
|
||||||
<Line x1={150} y1={262} x2={450} y2={262} dashed />
|
|
||||||
{[150, 250, 350, 450].map((x) => (
|
|
||||||
<g key={x}>
|
|
||||||
<Line x1={x} y1={262} x2={x} y2={292} dashed />
|
|
||||||
<rect x={x - 30} y={292} width={60} height={40} rx={6} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
|
|
||||||
<line x1={x - 40} y1={344} x2={x + 40} y2={344} stroke={TEAL} strokeWidth={3} strokeLinecap="round" />
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
<text x={300} y={392} textAnchor="middle" fontSize={20} fill={MUTED}>Endpoints built through the task sequence</text>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
voice: {
|
|
||||||
label: 'Microsoft Teams calling: number porting, dial-plan and call-flow design, then users supported by training, documentation and adoption monitoring.',
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
<Box x={20} y={50} w={160} h={70} label="Number" sub="porting" />
|
|
||||||
<Box x={220} y={50} w={160} h={70} label="Dial plan" accent />
|
|
||||||
<Box x={420} y={50} w={160} h={70} label="Call flow" />
|
|
||||||
<Arrow x1={180} y={85} x2={220} />
|
|
||||||
<Arrow x1={380} y={85} x2={420} />
|
|
||||||
<Line x1={300} y1={120} x2={300} y2={168} dashed />
|
|
||||||
<Line x1={100} y1={168} x2={500} y2={168} dashed />
|
|
||||||
{[100, 300, 500].map((x) => (
|
|
||||||
<g key={x}>
|
|
||||||
<Line x1={x} y1={168} x2={x} y2={196} dashed />
|
|
||||||
<circle cx={x} cy={214} r={16} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
|
|
||||||
<path d={`M${x - 28} ${264} Q${x} ${226} ${x + 28} ${264}`} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
<text x={100} y={310} textAnchor="middle" fontSize={20} fill={INK}>User training</text>
|
|
||||||
<text x={300} y={310} textAnchor="middle" fontSize={20} fill={INK}>Documentation</text>
|
|
||||||
<text x={500} y={310} textAnchor="middle" fontSize={20} fill={INK}>Adoption monitoring</text>
|
|
||||||
<text x={300} y={380} textAnchor="middle" fontSize={20} fill={MUTED}>Microsoft Teams calling</text>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ProjectDiagram({ kind, className, style }: { kind: DiagramKind; className?: string; style?: CSSProperties }) {
|
|
||||||
const d = diagrams[kind];
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 600 420" role="img" aria-label={`Illustrative diagram. ${d.label}`} className={className} style={{ fontFamily: 'Kanit, sans-serif', ...style }}>
|
|
||||||
{d.body}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
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';
|
|
||||||
import networkPhoto from '../assets/projects/network.jpg';
|
|
||||||
import serverPhoto from '../assets/projects/server.jpg';
|
|
||||||
import sccmPhoto from '../assets/projects/sccm.jpg';
|
|
||||||
import voicePhoto from '../assets/projects/voice.jpg';
|
|
||||||
|
|
||||||
import type { DiagramKind } from '../components/ProjectDiagram';
|
|
||||||
|
|
||||||
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'],
|
|
||||||
diagram: 'network' as DiagramKind,
|
|
||||||
photo: networkPhoto,
|
|
||||||
photoPosition: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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'],
|
|
||||||
diagram: 'server' as DiagramKind,
|
|
||||||
photo: serverPhoto,
|
|
||||||
photoPosition: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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'],
|
|
||||||
diagram: 'sccm' as DiagramKind,
|
|
||||||
photo: sccmPhoto,
|
|
||||||
photoPosition: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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'],
|
|
||||||
diagram: 'voice' as DiagramKind,
|
|
||||||
photo: voicePhoto,
|
|
||||||
photoPosition: 'center',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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,25 +0,0 @@
|
|||||||
@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 components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
html, body, #root { background: #0C0C0C; }
|
|
||||||
html { scroll-behavior: smooth; }
|
|
||||||
body { font-family: 'Kanit', sans-serif; }
|
|
||||||
|
|
||||||
.hero-heading {
|
|
||||||
background: linear-gradient(180deg, #646973 0%, #BBCCD7 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
html { scroll-behavior: auto; }
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { StrictMode } from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import App from './App';
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
</StrictMode>
|
|
||||||
);
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import FadeIn from '../components/FadeIn';
|
|
||||||
import AnimatedText from '../components/AnimatedText';
|
|
||||||
import ContactButton from '../components/ContactButton';
|
|
||||||
import { aboutText, images, stats } from '../data/content';
|
|
||||||
|
|
||||||
export default function AboutSection() {
|
|
||||||
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">
|
|
||||||
<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={images.signalMark} alt="" className="aspect-square w-full rounded-2xl object-cover" />
|
|
||||||
</FadeIn>
|
|
||||||
<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={images.systems} alt="" className="aspect-[4/3] w-full -rotate-6 rounded-2xl object-cover" />
|
|
||||||
</FadeIn>
|
|
||||||
<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={images.leadership} alt="" className="aspect-[3/2] w-full rotate-6 rounded-2xl object-cover" />
|
|
||||||
</FadeIn>
|
|
||||||
<FadeIn delay={0.3} x={80} y={0} duration={0.9} className="absolute bottom-[4%] right-[3%] sm:right-[6%] md:right-[10%]">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex flex-col items-center gap-10 sm:gap-14 md:gap-16">
|
|
||||||
<FadeIn delay={0} y={40}>
|
|
||||||
<h2 className="hero-heading text-center font-black uppercase leading-none tracking-tight" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
|
||||||
About me
|
|
||||||
</h2>
|
|
||||||
</FadeIn>
|
|
||||||
<AnimatedText
|
|
||||||
text={aboutText}
|
|
||||||
className="max-w-[560px] text-center font-medium leading-relaxed text-[#D7E2EA]"
|
|
||||||
style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10 mb-24 sm:mb-28 md:mb-0">
|
|
||||||
<ContactButton />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import FadeIn from '../components/FadeIn';
|
|
||||||
import Magnet from '../components/Magnet';
|
|
||||||
import ContactButton from '../components/ContactButton';
|
|
||||||
import { images, profile } from '../data/content';
|
|
||||||
|
|
||||||
const links = [
|
|
||||||
{ label: 'About', href: '#about' },
|
|
||||||
{ label: 'Skills', href: '#skills' },
|
|
||||||
{ label: 'Projects', href: '#projects' },
|
|
||||||
{ label: 'Contact', href: '#contact' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function HeroSection() {
|
|
||||||
return (
|
|
||||||
<section className="relative flex h-screen min-h-[600px] flex-col" style={{ overflowX: 'clip' }}>
|
|
||||||
<FadeIn delay={0} y={-20}>
|
|
||||||
<nav className="flex justify-between px-6 pt-6 md:px-10 md:pt-8" aria-label="Section navigation">
|
|
||||||
{links.map((l) => (
|
|
||||||
<a
|
|
||||||
key={l.label}
|
|
||||||
href={l.href}
|
|
||||||
className="text-sm font-medium uppercase tracking-wider text-[#D7E2EA] transition-opacity duration-200 hover:opacity-70 md:text-lg lg:text-[1.4rem]"
|
|
||||||
>
|
|
||||||
{l.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</FadeIn>
|
|
||||||
|
|
||||||
<div className="overflow-hidden">
|
|
||||||
<FadeIn delay={0.15} y={40}>
|
|
||||||
<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 {profile.firstName}
|
|
||||||
</h1>
|
|
||||||
</FadeIn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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}>
|
|
||||||
<Magnet padding={150} strength={3} activeTransition="transform 0.3s ease-out" inactiveTransition="transform 0.6s ease-in-out" className="!block">
|
|
||||||
<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>
|
|
||||||
</FadeIn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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}>
|
|
||||||
<p
|
|
||||||
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.7rem, 1.4vw, 1.5rem)' }}
|
|
||||||
>
|
|
||||||
{profile.tagline}
|
|
||||||
</p>
|
|
||||||
</FadeIn>
|
|
||||||
<FadeIn delay={0.5} y={20} className="relative z-20">
|
|
||||||
<ContactButton />
|
|
||||||
</FadeIn>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { useRef } from 'react';
|
|
||||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
|
||||||
import { Check } from 'lucide-react';
|
|
||||||
import FadeIn from '../components/FadeIn';
|
|
||||||
import ProjectDiagram from '../components/ProjectDiagram';
|
|
||||||
import { projects } from '../data/content';
|
|
||||||
|
|
||||||
type Project = (typeof projects)[number];
|
|
||||||
|
|
||||||
const radius = 'rounded-[40px] sm:rounded-[50px] md:rounded-[60px]';
|
|
||||||
|
|
||||||
function ProjectCard({ project, index, total }: { project: Project; index: number; total: number }) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const { scrollYProgress } = useScroll({ target: ref, offset: ['start end', 'start start'] });
|
|
||||||
const targetScale = 1 - (total - 1 - index) * 0.03;
|
|
||||||
const scale = useTransform(scrollYProgress, [0, 1], [1, targetScale]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="sticky top-24 min-h-[85vh]">
|
|
||||||
<motion.div
|
|
||||||
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' }}
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<span className="hero-heading font-black leading-none" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
|
|
||||||
{String(index + 1).padStart(2, '0')}
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 text-[#D7E2EA]">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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 className="flex flex-col gap-3 sm:flex-row sm:gap-4">
|
|
||||||
<div className="flex flex-col gap-3 sm:w-[40%] sm:gap-4">
|
|
||||||
<img
|
|
||||||
src={project.photo}
|
|
||||||
alt={`${project.name}: illustrative photo`}
|
|
||||||
loading="lazy"
|
|
||||||
className={`w-full object-cover ${radius}`}
|
|
||||||
style={{ height: 'clamp(150px, 16vw, 230px)', objectPosition: project.photoPosition }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`flex flex-1 flex-col justify-center gap-3 border border-[#D7E2EA]/25 p-5 sm:gap-4 sm:p-6 md:p-8 ${radius}`}
|
|
||||||
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)', minHeight: 'clamp(160px, 22vw, 340px)' }}
|
|
||||||
>
|
|
||||||
<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-3">
|
|
||||||
{project.delivered.map((d) => (
|
|
||||||
<li key={d} className="flex gap-2 font-light leading-snug text-[#D7E2EA]" style={{ fontSize: 'clamp(0.8rem, 1.3vw, 1.1rem)' }}>
|
|
||||||
<Check className="mt-0.5 h-4 w-4 shrink-0" />
|
|
||||||
<span>{d}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`order-first flex items-center justify-center border border-[#D7E2EA]/25 p-3 sm:order-none sm:w-[60%] sm:p-5 md:p-8 ${radius}`}
|
|
||||||
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)' }}
|
|
||||||
>
|
|
||||||
<ProjectDiagram kind={project.diagram} className="h-auto w-full" style={{ maxHeight: "clamp(240px, 30vw, 430px)" }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectsSection() {
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
<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)' }}>
|
|
||||||
Projects
|
|
||||||
</h2>
|
|
||||||
</FadeIn>
|
|
||||||
{projects.map((p, i) => (
|
|
||||||
<ProjectCard key={p.name} project={p} index={i} total={projects.length} />
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import FadeIn from '../components/FadeIn';
|
|
||||||
import { expertise } from '../data/content';
|
|
||||||
|
|
||||||
export default function ServicesSection() {
|
|
||||||
return (
|
|
||||||
<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)' }}>
|
|
||||||
Capability
|
|
||||||
</h2>
|
|
||||||
<div className="mx-auto max-w-5xl">
|
|
||||||
{expertise.map((s, i) => (
|
|
||||||
<FadeIn key={s.name} delay={(i % 4) * 0.1}>
|
|
||||||
<div
|
|
||||||
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)' }}
|
|
||||||
>
|
|
||||||
<span className="min-w-[1.6em] font-black leading-none text-[#0C0C0C]" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
|
|
||||||
{String(i + 1).padStart(2, '0')}
|
|
||||||
</span>
|
|
||||||
<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>
|
|
||||||
<p className="max-w-2xl font-light leading-relaxed opacity-60" style={{ fontSize: 'clamp(0.85rem, 1.6vw, 1.25rem)' }}>
|
|
||||||
{s.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FadeIn>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
|
||||||
theme: { extend: { fontFamily: { sans: ['Kanit', 'sans-serif'] } } },
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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 },
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>LookAtU – Know your child is safe</title>
|
||||||
|
<meta name="description" content="LookAtU helps parents see where their child is, guide their phone use, and gives kids a panic button for emergencies.">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#0b1220; --bg2:#111a2e; --card:#16213a; --text:#eaf0ff; --muted:#a3b1d1;
|
||||||
|
--brand:#4f8cff; --brand2:#7c5cff; --danger:#ff4d5e; --ok:#31d0a0; --line:#243253;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html{scroll-behavior:smooth}
|
||||||
|
body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);line-height:1.6}
|
||||||
|
a{color:inherit}
|
||||||
|
.wrap{max-width:1080px;margin:0 auto;padding:0 20px}
|
||||||
|
header{position:sticky;top:0;z-index:10;background:rgba(11,18,32,.85);backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
|
||||||
|
.nav{display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||||
|
.logo{font-weight:800;font-size:1.3rem;text-decoration:none}
|
||||||
|
.logo span{color:var(--brand)}
|
||||||
|
.nav a.link{margin-left:22px;text-decoration:none;color:var(--muted);font-size:.95rem}
|
||||||
|
.nav a.link:hover{color:var(--text)}
|
||||||
|
.btn{display:inline-block;border:0;cursor:pointer;font:inherit;font-weight:700;padding:12px 22px;border-radius:12px;color:#fff;text-decoration:none;background:linear-gradient(135deg,var(--brand),var(--brand2));transition:transform .15s}
|
||||||
|
.btn:hover{transform:translateY(-2px)}
|
||||||
|
.btn.ghost{background:transparent;border:1px solid var(--line);color:var(--text)}
|
||||||
|
.hero{padding:80px 0 60px;text-align:center;background:radial-gradient(900px 400px at 50% -10%,rgba(79,140,255,.25),transparent)}
|
||||||
|
.hero h1{font-size:clamp(2.2rem,6vw,3.8rem);line-height:1.1;margin:0 0 18px}
|
||||||
|
.hero h1 em{font-style:normal;background:linear-gradient(135deg,var(--brand),var(--brand2));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||||
|
.hero p{max-width:620px;margin:0 auto 28px;color:var(--muted);font-size:1.15rem}
|
||||||
|
.cta{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
|
||||||
|
.phone{margin:48px auto 0;width:240px;border:2px solid var(--line);border-radius:34px;padding:18px;background:var(--bg2);box-shadow:0 20px 60px rgba(0,0,0,.4)}
|
||||||
|
.map{height:130px;border-radius:16px;background:linear-gradient(135deg,#1c2b4d,#22365f);position:relative;overflow:hidden}
|
||||||
|
.pin{position:absolute;left:50%;top:50%;width:16px;height:16px;margin:-8px;border-radius:50%;background:var(--ok)}
|
||||||
|
.pin::after{content:"";position:absolute;inset:-10px;border-radius:50%;border:2px solid var(--ok);animation:pulse 2s infinite}
|
||||||
|
@keyframes pulse{from{transform:scale(.6);opacity:1}to{transform:scale(1.8);opacity:0}}
|
||||||
|
.phone p{margin:12px 0 4px;font-size:.9rem;color:var(--muted)}
|
||||||
|
.panic{margin-top:6px;width:100%;padding:12px;border:0;border-radius:14px;background:var(--danger);color:#fff;font-weight:800;font-size:1rem;cursor:pointer}
|
||||||
|
section{padding:70px 0}
|
||||||
|
section.alt{background:var(--bg2)}
|
||||||
|
h2{font-size:clamp(1.6rem,4vw,2.4rem);margin:0 0 10px;text-align:center}
|
||||||
|
.sub{text-align:center;color:var(--muted);max-width:620px;margin:0 auto 40px}
|
||||||
|
.grid{display:grid;gap:18px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
|
||||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:24px}
|
||||||
|
.card .ic{font-size:2rem}
|
||||||
|
.card h3{margin:10px 0 6px}
|
||||||
|
.card p{margin:0;color:var(--muted)}
|
||||||
|
.card.sos{border-color:var(--danger)}
|
||||||
|
.steps{counter-reset:s}
|
||||||
|
.steps .card{position:relative;padding-top:50px}
|
||||||
|
.steps .card::before{counter-increment:s;content:counter(s);position:absolute;top:14px;left:20px;width:28px;height:28px;border-radius:50%;background:var(--brand);display:grid;place-items:center;font-weight:800}
|
||||||
|
.trust li{margin:10px 0;color:var(--muted)}
|
||||||
|
.trust b{color:var(--text)}
|
||||||
|
details{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px 20px;margin:10px auto;max-width:720px}
|
||||||
|
summary{cursor:pointer;font-weight:700}
|
||||||
|
details p{color:var(--muted);margin:10px 0 0}
|
||||||
|
form{max-width:480px;margin:0 auto;display:flex;gap:10px;flex-wrap:wrap}
|
||||||
|
input[type=email]{flex:1 1 220px;padding:13px 14px;border-radius:12px;border:1px solid var(--line);background:var(--bg);color:var(--text);font:inherit}
|
||||||
|
input[type=email]:focus-visible,.btn:focus-visible,summary:focus-visible,button:focus-visible{outline:3px solid var(--brand);outline-offset:2px}
|
||||||
|
#msg{text-align:center;margin-top:14px;min-height:1.5em}
|
||||||
|
#msg.ok{color:var(--ok)} #msg.err{color:var(--danger)}
|
||||||
|
.note{text-align:center;color:var(--muted);font-size:.85rem;margin-top:8px}
|
||||||
|
.sr{position:absolute;left:-9999px}
|
||||||
|
.trap{position:absolute;left:-9999px;height:0;overflow:hidden}
|
||||||
|
select{padding:13px 14px;border-radius:12px;border:1px solid var(--line);background:var(--bg);color:var(--text);font:inherit}
|
||||||
|
.consent{flex:1 1 100%;font-size:.9rem;color:var(--muted)}
|
||||||
|
.consent input{margin-right:8px}
|
||||||
|
.consent a{color:var(--brand)}
|
||||||
|
footer a{margin:0 8px}
|
||||||
|
footer{padding:30px 0;border-top:1px solid var(--line);color:var(--muted);font-size:.9rem;text-align:center}
|
||||||
|
@media (prefers-reduced-motion:reduce){.pin::after{animation:none}html{scroll-behavior:auto}}
|
||||||
|
@media (max-width:640px){.nav a.link{display:none}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="wrap nav">
|
||||||
|
<a class="logo" href="#top">Look<span>At</span>U</a>
|
||||||
|
<nav>
|
||||||
|
<a class="link" href="#features">Features</a>
|
||||||
|
<a class="link" href="#how">How it works</a>
|
||||||
|
<a class="link" href="#trust">Trust</a>
|
||||||
|
<a class="link" href="#faq">FAQ</a>
|
||||||
|
<a class="btn" style="margin-left:22px;padding:9px 16px" href="#join">Join waitlist</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
<div class="hero">
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Know your child is <em>safe</em>,<br>anytime you want.</h1>
|
||||||
|
<p>See where they are, guide how they use their phone, and give them a big red button to call for help when it matters most.</p>
|
||||||
|
<div class="cta">
|
||||||
|
<a class="btn" href="#join">Join the waitlist</a>
|
||||||
|
<a class="btn ghost" href="#features">See what it does</a>
|
||||||
|
</div>
|
||||||
|
<div class="phone" aria-hidden="true">
|
||||||
|
<div class="map"><div class="pin"></div></div>
|
||||||
|
<p>Alex is at school · just now</p>
|
||||||
|
<button class="panic" tabindex="-1">SOS</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="features">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2>Everything a parent needs</h2>
|
||||||
|
<p class="sub">Five simple tools, in one app.</p>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><div class="ic">📍</div><h3>Live location</h3><p>Open the app and see where your child is right now.</p></div>
|
||||||
|
<div class="card"><div class="ic">📊</div><h3>Activity monitor</h3><p>See which apps they use and for how long, every day.</p></div>
|
||||||
|
<div class="card"><div class="ic">🔒</div><h3>App control</h3><p>Allow, block or limit apps. Approve new installs before they happen.</p></div>
|
||||||
|
<div class="card"><div class="ic">💬</div><h3>Calls & messages</h3><p>Check call history and messages so you can spot trouble early.</p></div>
|
||||||
|
<div class="card sos"><div class="ic">🚨</div><h3>Panic button</h3><p>One tap and you get an alert with their location, right away.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="how" class="alt">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2>How it works</h2>
|
||||||
|
<p class="sub">Set up takes a few minutes.</p>
|
||||||
|
<div class="grid steps">
|
||||||
|
<div class="card"><h3>Install on both phones</h3><p>You get the parent app. Your child gets the child app.</p></div>
|
||||||
|
<div class="card"><h3>Link them together</h3><p>Scan a code on your child's phone to connect the two.</p></div>
|
||||||
|
<div class="card"><h3>Choose your rules</h3><p>Pick which apps are allowed and what you want to see.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="trust">
|
||||||
|
<div class="wrap" style="max-width:720px">
|
||||||
|
<h2>Built on trust, not secrets</h2>
|
||||||
|
<p class="sub">Safety works best when families talk about it.</p>
|
||||||
|
<ul class="trust">
|
||||||
|
<li><b>Your child knows.</b> The app is always visible on their phone. LookAtU is never hidden.</li>
|
||||||
|
<li><b>Private by design.</b> Data is encrypted and only the parent account can see it.</li>
|
||||||
|
<li><b>You stay in charge.</b> Choose what to watch and turn features on or off at any time.</li>
|
||||||
|
<li><b>Follows the law.</b> Rules about monitoring differ by country and a child's age. We check them before we launch anywhere.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="faq" class="alt">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2>Questions</h2>
|
||||||
|
<p class="sub"> </p>
|
||||||
|
<details><summary>Is LookAtU available now?</summary><p>Not yet. This page is for our waitlist. Join and we will email you when it opens.</p></details>
|
||||||
|
<details><summary>Can my child hide the app?</summary><p>No. The app is always visible, and your child knows it is there.</p></details>
|
||||||
|
<details><summary>What happens when my child presses the panic button?</summary><p>You get an instant alert with your child's location, so you can call them or get help.</p></details>
|
||||||
|
<details><summary>Which phones will it work on?</summary><p>We plan to start with Android, then iPhone. Tell us which one you need when you join.</p></details>
|
||||||
|
<details><summary>How much will it cost?</summary><p>We have not set the price yet. Waitlist members will hear first.</p></details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="join">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2>Join the waitlist</h2>
|
||||||
|
<p class="sub">Be the first to know when LookAtU opens.</p>
|
||||||
|
<form id="waitlist" novalidate>
|
||||||
|
<label for="email" class="sr">Email address</label>
|
||||||
|
<input id="email" name="email" type="email" placeholder="you@example.com" autocomplete="email" required>
|
||||||
|
<label for="phone" class="sr">Your child's phone</label>
|
||||||
|
<select id="phone" name="phone">
|
||||||
|
<option value="Android">Android phone</option>
|
||||||
|
<option value="iPhone">iPhone</option>
|
||||||
|
<option value="Both">Both</option>
|
||||||
|
</select>
|
||||||
|
<div class="trap" aria-hidden="true"><label for="website">Leave this empty</label><input id="website" name="website" type="text" tabindex="-1" autocomplete="off"></div>
|
||||||
|
<label class="consent" for="agree"><input id="agree" name="agree" type="checkbox"> I agree to the <a href="privacy/">Privacy Policy</a> and want emails about LookAtU.</label>
|
||||||
|
<button class="btn" id="send" type="submit">Notify me</button>
|
||||||
|
</form>
|
||||||
|
<div id="msg" role="status" aria-live="polite"></div>
|
||||||
|
<p class="note" id="note">We only use your email to tell you when LookAtU opens. No spam.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer><div class="wrap">© <span id="yr">2026</span> LookAtU. Early preview. <a href="privacy/">Privacy</a> <a href="terms/">Terms</a></div></footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// PASTE YOUR FORM URL BETWEEN THE QUOTES (example: https://formspree.io/f/abcdwxyz). While empty, the form runs in demo mode.
|
||||||
|
var FORM_ENDPOINT = 'https://formspree.io/f/mljgogkr';
|
||||||
|
(function(){
|
||||||
|
var form=document.getElementById('waitlist'),email=document.getElementById('email'),msg=document.getElementById('msg'),
|
||||||
|
btn=document.getElementById('send'),note=document.getElementById('note');
|
||||||
|
document.getElementById('yr').textContent=new Date().getFullYear();
|
||||||
|
if(!FORM_ENDPOINT){note.textContent='Demo mode: emails are not saved yet.';}
|
||||||
|
function say(cls,t){msg.className=cls;msg.textContent=t;msg.scrollIntoView({block:'nearest'});}
|
||||||
|
form.addEventListener('submit',function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
var v=email.value.trim();
|
||||||
|
if(!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v)){say('err','Please enter a real email address, like you@example.com.');email.focus();return;}
|
||||||
|
if(!document.getElementById('agree').checked){say('err','Please tick the box to agree to the Privacy Policy.');return;}
|
||||||
|
if(document.getElementById('website').value){say('ok','Thank you! You are on the list.');return;}
|
||||||
|
if(!FORM_ENDPOINT){say('ok','Thank you! You are on the list (demo, not saved).');form.reset();return;}
|
||||||
|
btn.disabled=true;say('','Sending...');
|
||||||
|
var data=new FormData();data.append('email',v);data.append('phone',document.getElementById('phone').value);
|
||||||
|
fetch(FORM_ENDPOINT,{method:'POST',headers:{'Accept':'application/json'},body:data})
|
||||||
|
.then(function(r){
|
||||||
|
if(!r.ok){return r.text().then(function(t){throw new Error('Error '+r.status+' '+t.slice(0,120));});}
|
||||||
|
say('ok','Thank you! You are on the list.');form.reset();
|
||||||
|
})
|
||||||
|
.catch(function(err){
|
||||||
|
say('err','Could not send. ('+(err&&err.message?err.message:'network problem')+') Please try again in a minute.');
|
||||||
|
if(window.console)console.error('Waitlist error:',err);
|
||||||
|
})
|
||||||
|
.then(function(){btn.disabled=false;msg.scrollIntoView({block:'nearest'});});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script type="module">
|
||||||
|
// Framer Motion ("motion") adds the animations. If it fails to load, the page still shows everything.
|
||||||
|
try{
|
||||||
|
var m=await import('https://cdn.jsdelivr.net/npm/motion@11.11.13/+esm');
|
||||||
|
if(!matchMedia('(prefers-reduced-motion: reduce)').matches){
|
||||||
|
m.animate('.hero h1,.hero p,.hero .cta',{opacity:[0,1],y:[24,0]},{duration:.7,delay:m.stagger(.12)});
|
||||||
|
m.animate('.phone',{opacity:[0,1],y:[40,0]},{duration:.8,delay:.4});
|
||||||
|
document.querySelectorAll('.card').forEach(function(c){
|
||||||
|
m.inView(c,function(){m.animate(c,{opacity:[0,1],y:[24,0]},{duration:.5});});
|
||||||
|
c.addEventListener('mouseenter',function(){m.animate(c,{y:-4},{duration:.2});});
|
||||||
|
c.addEventListener('mouseleave',function(){m.animate(c,{y:0},{duration:.2});});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Privacy Policy – LookAtU</title>
|
||||||
|
<style>
|
||||||
|
body{margin:0;background:#0b1220;color:#eaf0ff;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;line-height:1.7}
|
||||||
|
main{max-width:720px;margin:0 auto;padding:40px 20px}
|
||||||
|
a{color:#4f8cff}h1{margin-top:0}h2{margin-top:32px}
|
||||||
|
.draft{background:#16213a;border:1px solid #ff4d5e;border-radius:12px;padding:14px 18px;color:#a3b1d1}
|
||||||
|
</style></head><body><main>
|
||||||
|
<p><a href="../">← Back to LookAtU</a></p>
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<p class="draft"><strong>Draft.</strong> This page is a plain-language starting point. A lawyer must review it before LookAtU launches, because rules for children's data differ by country.</p>
|
||||||
|
|
||||||
|
<h2>What we collect today</h2>
|
||||||
|
<p>This website collects only the email address and phone type (Android, iPhone or both) that you type into the waitlist form.</p>
|
||||||
|
<h2>Why we collect it</h2>
|
||||||
|
<p>We use it only to tell you when LookAtU opens. We do not sell it. We do not use it for anything else.</p>
|
||||||
|
<h2>Who sees it</h2>
|
||||||
|
<p>The LookAtU team, and the service that stores the waitlist for us.</p>
|
||||||
|
<h2>Your choices</h2>
|
||||||
|
<p>You can ask us to delete your email at any time. Every email we send will have a way to unsubscribe.</p>
|
||||||
|
<h2>The LookAtU app (coming later)</h2>
|
||||||
|
<p>The app will handle location, activity and messages of children. Before launch we will publish full details here: what is collected, how it is protected, how long it is kept, and how a parent can delete it. The child will always be able to see that the app is installed.</p>
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>Contact email: <em>to be added before launch</em>.</p>
|
||||||
|
</main></body></html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Terms of Use – LookAtU</title>
|
||||||
|
<style>
|
||||||
|
body{margin:0;background:#0b1220;color:#eaf0ff;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;line-height:1.7}
|
||||||
|
main{max-width:720px;margin:0 auto;padding:40px 20px}
|
||||||
|
a{color:#4f8cff}h1{margin-top:0}h2{margin-top:32px}
|
||||||
|
.draft{background:#16213a;border:1px solid #ff4d5e;border-radius:12px;padding:14px 18px;color:#a3b1d1}
|
||||||
|
</style></head><body><main>
|
||||||
|
<p><a href="../">← Back to LookAtU</a></p>
|
||||||
|
<h1>Terms of Use</h1>
|
||||||
|
<p class="draft"><strong>Draft.</strong> This page is a plain-language starting point. A lawyer must review it before LookAtU launches, because rules for children's data differ by country.</p>
|
||||||
|
|
||||||
|
<h2>This is an early preview</h2>
|
||||||
|
<p>This website is a preview of a product that is not released yet. Nothing here is an offer to sell.</p>
|
||||||
|
<h2>Waitlist</h2>
|
||||||
|
<p>Joining the waitlist is free and does not oblige you to buy anything. You can leave at any time.</p>
|
||||||
|
<h2>Using LookAtU responsibly (when it launches)</h2>
|
||||||
|
<ul>
|
||||||
|
<li>LookAtU is for parents and legal guardians of their own children.</li>
|
||||||
|
<li>It must not be used to watch another adult or someone else's child.</li>
|
||||||
|
<li>The child must be able to see that the app is installed.</li>
|
||||||
|
<li>Parents are responsible for following the laws in their country.</li>
|
||||||
|
</ul>
|
||||||
|
<h2>Emergencies</h2>
|
||||||
|
<p>The panic button helps a child alert a parent. It does not replace the police or emergency services. In an emergency, call your local emergency number.</p>
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>Contact email: <em>to be added before launch</em>.</p>
|
||||||
|
</main></body></html>
|
||||||