Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a29279f0f | ||
|
|
61cc2383fc | ||
|
|
08befffdeb | ||
|
|
87dc305496 | ||
|
|
9c4e09e420 |
No files matched your search
Whitespace-only changes.
@@ -1,163 +0,0 @@
|
||||
---
|
||||
name: ad-creative-video
|
||||
description: This skill should be used when the user asks to "make a video ad", "create an animated ad", "build a performance/UGC-style ad", "batch-produce ad creative variations", "generate ad variants for A/B testing", "swap headline/offer/CTA across many ad versions", or "export one ad in multiple aspect ratios for Meta/TikTok/Reels". Covers ad hook structures, hook→CTA message-match, data-driven variant generation (1 template × CSV = N ads), multi-aspect export, and platform specs.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Ad Creative Video
|
||||
|
||||
Build ONE motion-graphics ad template, then mass-produce variants from a data table — different hooks, offers, CTAs, and products — so a performance marketer can A/B test dozens of versions across placements and aspect ratios. The craft is making the template fully data-driven and message-matched, so 40 variants stay on-brand and only the tested variable changes.
|
||||
|
||||
## When to use
|
||||
|
||||
- Performance/UGC-style video ads where the first 1.5–3s must stop the scroll.
|
||||
- Batch variant generation: one template rendered against many rows (headline/offer/CTA/product swaps) for A/B testing.
|
||||
- Multi-aspect export of the same ad for Meta Feed, Reels/Stories, TikTok, YouTube.
|
||||
|
||||
Not for sale/discount countdown promos, and not for testimonial/review-driven ads — those are different structures. Stay on the batch-variation, test-everything angle.
|
||||
|
||||
## The two rules that make variants worth running
|
||||
|
||||
1. **Isolate one variable per variant.** A test only teaches something if exactly one thing changes. Hold layout, motion, colors, and timing constant; swap *only* the field under test (hook, or offer, or CTA). Mixing two changes makes the winner uninterpretable.
|
||||
2. **Message-match the hook to the CTA.** The promise made in the first 3 seconds must be the promise the button pays off. A "stop wasting 2 hours a day" hook ends on "Save 2 hours — try free," not a generic "Shop now." The hook and CTA are a matched pair in every row of the data table.
|
||||
|
||||
## Ad anatomy (15–30s)
|
||||
|
||||
| Beat | Job | Budget |
|
||||
|---|---|---|
|
||||
| Hook (0–3s) | Stop the scroll; state the problem or pattern-interrupt | 0–3s |
|
||||
| Context | Make the viewer feel the problem / agitate | 3–8s |
|
||||
| Payoff | Show the product as the solution, one clear benefit | 8–18s |
|
||||
| Proof | One concrete number, demo, or result | 18–25s |
|
||||
| CTA (last 3s) | Single action, message-matched to the hook | hold ≥2s |
|
||||
|
||||
Land the hook's emotional trigger before the 2-second mark — judgment forms in ~1.7s and scroll speed keeps rising. Test the hook FIRST; it moves CPA more than any other element.
|
||||
|
||||
## Hook taxonomy (the field you vary most)
|
||||
|
||||
| Hook type | Template | When it wins |
|
||||
|---|---|---|
|
||||
| Problem-first | "I didn't realize how much X was costing me until…" | pain is felt but unnamed |
|
||||
| Pattern interrupt | "This is going to sound controversial, but…" | crowded feed, generic category |
|
||||
| Curiosity gap | "Nobody talks about the one thing that…" | educated, skeptical audience |
|
||||
| Immediate benefit | "Cut your X in half in 7 days" | clear, quantifiable outcome |
|
||||
| Direct callout | "If you do X every morning, stop." | sharp audience segment |
|
||||
|
||||
Keep a column of 5–10 hook strings and let the batch render produce one ad per hook against the same body — that is the cleanest hook test possible.
|
||||
|
||||
## Data-driven template (everything is a prop)
|
||||
|
||||
Hardcode nothing the marketer might A/B test. The composition reads a single `variant` object; the renderer never edits the component. Make every animated value a pure function of `useCurrentFrame()` so each frame renders deterministically (no CSS transitions, no library timers — they desync the render).
|
||||
|
||||
```tsx
|
||||
import {useCurrentFrame, interpolate, AbsoluteFill, useVideoConfig} from "remotion";
|
||||
|
||||
type Variant = {hook: string; benefit: string; proof: string; cta: string; bg: string; accent: string};
|
||||
|
||||
export const AdTemplate: React.FC<{v: Variant}> = ({v}) => {
|
||||
const frame = useCurrentFrame(); const {fps} = useVideoConfig();
|
||||
const hookIn = interpolate(frame, [0, 8], [40, 0], {extrapolateRight: "clamp"});
|
||||
const ctaIn = interpolate(frame, [fps * 22, fps * 23], [0, 1], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
|
||||
return (
|
||||
<AbsoluteFill style={{background: v.bg, fontFamily: "Inter, sans-serif"}}>
|
||||
<h1 style={{transform: `translateY(${hookIn}px)`, opacity: interpolate(frame,[0,8],[0,1])}}>{v.hook}</h1>
|
||||
{frame > fps * 8 && <p>{v.benefit}</p>}
|
||||
{frame > fps * 18 && <strong>{v.proof}</strong>}
|
||||
<button style={{opacity: ctaIn, background: v.accent}}>{v.cta}</button>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
See `references/batch-ad-pipeline.md` for the complete template, the CSV→props parser with validation, and the brand-lock theme object.
|
||||
|
||||
## 1 template × CSV = N variants
|
||||
|
||||
The payoff: author the ad once, then let a CSV drive the matrix. Each row is one fully-formed, message-matched variant. Render once per row.
|
||||
|
||||
```bash
|
||||
# variants.csv: id,hook,benefit,proof,cta,bg,accent → one MP4 per row
|
||||
node csv-to-props.js variants.csv ./props # writes props/<id>.json per row
|
||||
for f in props/*.json; do
|
||||
id=$(basename "$f" .json)
|
||||
npx remotion render AdTemplate "out/${id}.mp4" --props="$f"
|
||||
done
|
||||
```
|
||||
|
||||
To build a clean test matrix, hold every column constant and vary one: 5 hooks × 1 body = 5 ads (hook test); then take the winning hook × 3 CTAs (CTA test). `references/batch-ad-pipeline.md` has a matrix generator that expands variable lists into the CSV so combinatorial tests stay one-variable-at-a-time.
|
||||
|
||||
## Multi-aspect export
|
||||
|
||||
Each placement wants a native aspect. Compose against a center safe zone so one master reframes cleanly to all of them — don't letterbox, re-center.
|
||||
|
||||
| Aspect | Placements | Resolution | Keep-clear safe zone |
|
||||
|---|---|---|---|
|
||||
| 9:16 | Reels, Stories, TikTok In-Feed | 1080×1920 | top ~14%, bottom ~20–35% (UI/caption), sides ~6% |
|
||||
| 4:5 | Meta Feed (best feed real estate) | 1080×1350 | center 90%; CTA out of bottom 10% |
|
||||
| 1:1 | Feed, broad reach | 1080×1080 | center square survives every crop |
|
||||
| 16:9 | YouTube, in-stream | 1920×1080 | center 90% |
|
||||
|
||||
Design the hook text, product, and CTA inside the **1:1 center square** so they survive every crop. Drive aspect from a prop and render each per variant:
|
||||
|
||||
```bash
|
||||
for ar in 9x16 4x5 1x1; do
|
||||
npx remotion render AdTemplate "out/${id}-${ar}.mp4" --props="props/${id}.json" \
|
||||
--props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done
|
||||
```
|
||||
|
||||
Keep critical content out of the 9:16 bottom third — that is where the platform stacks captions, the CTA button, and the username. `references/platform-specs.md` has exact pixel safe zones and per-platform duration/format limits.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- One variable per variant; layout/motion/timing identical across a test.
|
||||
- Hook lands its trigger before 2s; hook and CTA are message-matched in every row.
|
||||
- Every animated value is a pure function of `useCurrentFrame()` — no CSS/library timers.
|
||||
- Dataset is a prop; nothing testable is hardcoded; one template renders the whole CSV.
|
||||
- Each variant exported 9:16 + 4:5 + 1:1 (+16:9 if needed), key content in the center square.
|
||||
- Brand stays locked across all variants via a single theme object.
|
||||
|
||||
## Deliver & verify (rendered stills → MP4)
|
||||
|
||||
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
|
||||
|
||||
**Output contract:**
|
||||
- A Remotion ad template registered as `<Composition>` (+ zod `schema` + `defaultProps`), every animated value frame-driven (no CSS transitions / library timers / `Date.now()` / `Math.random()`).
|
||||
- Deliverable = the rendered `out/*.mp4` per variant per aspect (plus the project + CSV, so the marketer re-renders on new rows).
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Cheap PNGs first, full encode only once they're clean. Render with the **shipped** props (the real row), not just `defaultProps`.
|
||||
|
||||
```bash
|
||||
# Frame-exact stills across the hook→CTA arc, with a real variant's props
|
||||
npx remotion still AdTemplate out/f-hook.png --frame=12 --props=props/v1.json # hook readable < 2s
|
||||
npx remotion still AdTemplate out/f-mid.png --frame=300 --props=props/v1.json # benefit/proof
|
||||
npx remotion still AdTemplate out/f-cta.png --frame=689 --props=props/v1.json # CTA, message-matched
|
||||
# inspect each: fidelity (hook / offer / proof / CTA text exact, brand bg+accent right)
|
||||
# AND artifacts (text overflow, off-canvas, CTA inside the 9:16 bottom third, missing font, wrong row binding)
|
||||
```
|
||||
|
||||
**Multi-aspect / batch — verify one variant in EACH aspect before batch-rendering the matrix.** A layout bug repeats across every row × aspect; catch it once.
|
||||
|
||||
```bash
|
||||
for ar in 9x16 4x5 1x1; do # one representative variant, every target aspect
|
||||
npx remotion still AdTemplate "out/v1-${ar}.png" --frame=300 \
|
||||
--props=props/v1.json --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done
|
||||
# stills clean in all aspects? → then batch-render every row × aspect:
|
||||
for f in props/*.json; do id=$(basename "$f" .json); for ar in 9x16 4x5 1x1; do
|
||||
npx remotion render AdTemplate "out/${id}-${ar}.mp4" --props="$f" --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done; done
|
||||
npx remotion render AdTemplate out/demo.gif --codec=gif --props=props/v1.json # README demo
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. `npx remotion still` renders cleanly at hook / mid / CTA — no errors, no missing fonts/assets.
|
||||
2. Hook/offer/proof/CTA text exact and brand colors right; nothing in the 9:16 bottom third or outside the center safe zone.
|
||||
3. Frame-driven only — no CSS/library timers, `Date.now()`, or `Math.random()`.
|
||||
4. Shipped row's props render correctly (not just `defaultProps`); one variable per variant holds.
|
||||
5. One variant verified in 9:16 + 4:5 + 1:1 before the batch; MP4s play; (optional) GIF for the README.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/batch-ad-pipeline.md` — the full runnable Remotion ad template, the CSV→typed-props parser with validation, a one-variable test-matrix generator, the brand-lock theme object, and the complete batch + multi-aspect render script.
|
||||
- `references/platform-specs.md` — exact 2025/2026 pixel safe zones, durations, aspect ratios, and format limits for Meta Feed/Reels/Stories, TikTok In-Feed, and YouTube, plus a hook→CTA message-match worksheet.
|
||||
@@ -1,289 +0,0 @@
|
||||
# Batch Ad Pipeline — full template, parser, matrix, render
|
||||
|
||||
This is the complete, runnable implementation behind the SKILL.md overview: a data-driven Remotion ad template, a CSV→props parser with validation, a one-variable test-matrix generator, a brand-lock theme, and the batch + multi-aspect render scripts. Hardcode nothing a marketer might want to A/B test.
|
||||
|
||||
## 1. The brand-lock theme
|
||||
|
||||
Keep every brand-controlled value in one object so 40 variants stay consistent and only the *tested* fields change per row. The variant data supplies copy and per-campaign accents; the theme supplies the locked look.
|
||||
|
||||
```ts
|
||||
// theme.ts
|
||||
export const theme = {
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
ink: "#0B0B0F",
|
||||
paper: "#FFFFFF",
|
||||
radius: 20,
|
||||
hookSize: 76, // px, on the 1080-wide master
|
||||
bodySize: 44,
|
||||
proofSize: 52,
|
||||
ctaSize: 40,
|
||||
pad: 80, // outer padding; also the side safe margin
|
||||
durationInSeconds: 25,
|
||||
} as const;
|
||||
```
|
||||
|
||||
## 2. Aspect config
|
||||
|
||||
Drive layout from a single aspect prop so one composition reframes to every placement. Dimensions and the keep-clear bottom band differ per aspect.
|
||||
|
||||
```ts
|
||||
// aspects.ts
|
||||
export type Aspect = "9x16" | "4x5" | "1x1" | "16x9";
|
||||
export const ASPECTS: Record<Aspect, {w: number; h: number; bottomSafe: number}> = {
|
||||
"9x16": {w: 1080, h: 1920, bottomSafe: 540}, // captions + CTA + username stack here
|
||||
"4x5": {w: 1080, h: 1350, bottomSafe: 135},
|
||||
"1x1": {w: 1080, h: 1080, bottomSafe: 110},
|
||||
"16x9": {w: 1920, h: 1080, bottomSafe: 110},
|
||||
};
|
||||
```
|
||||
|
||||
## 3. The data-driven template
|
||||
|
||||
Every animated value is a pure function of `useCurrentFrame()` — no CSS transitions, no GSAP/library timers (they desync deterministic rendering and flicker in the export). The component reads one `variant` plus an `aspect`; the renderer never edits this file.
|
||||
|
||||
```tsx
|
||||
// AdTemplate.tsx
|
||||
import {
|
||||
AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, spring, Sequence,
|
||||
} from "remotion";
|
||||
import {theme} from "./theme";
|
||||
import {ASPECTS, Aspect} from "./aspects";
|
||||
|
||||
export type Variant = {
|
||||
id: string;
|
||||
hook: string; // 0–3s scroll-stopper
|
||||
benefit: string; // the single payoff claim
|
||||
proof: string; // one concrete number / result
|
||||
cta: string; // message-matched to the hook
|
||||
accent: string; // per-campaign accent color
|
||||
bg: string; // background color
|
||||
};
|
||||
|
||||
export const AdTemplate: React.FC<{v: Variant; aspect: Aspect}> = ({v, aspect}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const {fps} = useVideoConfig();
|
||||
const {bottomSafe} = ASPECTS[aspect];
|
||||
|
||||
// Hook rises + fades in over the first 8 frames, holds, then eases up and out at 8s.
|
||||
const hookY = interpolate(frame, [0, 8], [48, 0], {extrapolateRight: "clamp"});
|
||||
const hookOpacity =
|
||||
interpolate(frame, [0, 8], [0, 1], {extrapolateRight: "clamp"}) *
|
||||
interpolate(frame, [fps * 7, fps * 8], [1, 0], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
|
||||
|
||||
// CTA springs in for impact in the last 3s and holds.
|
||||
const ctaProgress = spring({frame: frame - fps * 22, fps, config: {damping: 14, stiffness: 120}});
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{backgroundColor: v.bg, fontFamily: theme.fontFamily, color: theme.ink}}>
|
||||
<AbsoluteFill style={{padding: theme.pad, paddingBottom: bottomSafe, justifyContent: "center"}}>
|
||||
|
||||
{/* HOOK 0–8s */}
|
||||
<Sequence durationInFrames={fps * 8}>
|
||||
<h1 style={{fontSize: theme.hookSize, fontWeight: 800, lineHeight: 1.05,
|
||||
transform: `translateY(${hookY}px)`, opacity: hookOpacity}}>
|
||||
{v.hook}
|
||||
</h1>
|
||||
</Sequence>
|
||||
|
||||
{/* BENEFIT 8–18s */}
|
||||
<Sequence from={fps * 8} durationInFrames={fps * 10}>
|
||||
<FadeUp delay={0}>
|
||||
<p style={{fontSize: theme.bodySize, fontWeight: 600}}>{v.benefit}</p>
|
||||
</FadeUp>
|
||||
</Sequence>
|
||||
|
||||
{/* PROOF 18–22s */}
|
||||
<Sequence from={fps * 18} durationInFrames={fps * 4}>
|
||||
<FadeUp delay={0}>
|
||||
<strong style={{fontSize: theme.proofSize, color: v.accent,
|
||||
fontVariantNumeric: "tabular-nums"}}>{v.proof}</strong>
|
||||
</FadeUp>
|
||||
</Sequence>
|
||||
|
||||
{/* CTA last 3s, message-matched to the hook */}
|
||||
<Sequence from={fps * 22}>
|
||||
<button style={{
|
||||
alignSelf: "flex-start", marginTop: 40, padding: "22px 44px",
|
||||
fontSize: theme.ctaSize, fontWeight: 700, border: "none",
|
||||
borderRadius: theme.radius, color: theme.paper, backgroundColor: v.accent,
|
||||
transform: `scale(${interpolate(ctaProgress, [0, 1], [0.85, 1])})`,
|
||||
opacity: ctaProgress,
|
||||
}}>
|
||||
{v.cta}
|
||||
</button>
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const FadeUp: React.FC<{children: React.ReactNode; delay: number}> = ({children, delay}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const o = interpolate(frame - delay, [0, 10], [0, 1], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
|
||||
const y = interpolate(frame - delay, [0, 10], [24, 0], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
|
||||
return <div style={{opacity: o, transform: `translateY(${y}px)`}}>{children}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
## 4. Register the composition (aspect + variant as props)
|
||||
|
||||
```tsx
|
||||
// Root.tsx
|
||||
import {Composition} from "remotion";
|
||||
import {AdTemplate} from "./AdTemplate";
|
||||
import {ASPECTS} from "./aspects";
|
||||
import {theme} from "./theme";
|
||||
|
||||
const defaultVariant = {
|
||||
id: "demo", hook: "Spending 2 hrs/day on reports?", benefit: "Auto-build them in one click.",
|
||||
proof: "Teams save 11 hrs a week.", cta: "Save 2 hours — try free", accent: "#4F46E5", bg: "#FFFFFF",
|
||||
};
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
const aspect = "9x16" as const; // overridden per-render via --props
|
||||
const {w, h} = ASPECTS[aspect];
|
||||
return (
|
||||
<Composition
|
||||
id="AdTemplate"
|
||||
component={AdTemplate}
|
||||
durationInFrames={theme.durationInSeconds * 30}
|
||||
fps={30}
|
||||
width={w}
|
||||
height={h}
|
||||
defaultProps={{v: defaultVariant, aspect}}
|
||||
// Resolve real dimensions from the incoming aspect prop at render time:
|
||||
calculateMetadata={({props}) => {
|
||||
const a = ASPECTS[(props as any).aspect ?? "9x16"];
|
||||
return {width: a.w, height: a.h};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
`calculateMetadata` lets a single composition output any aspect from the `aspect` prop — no separate composition per ratio.
|
||||
|
||||
## 5. CSV → typed props, with validation
|
||||
|
||||
The dataset is the input. Parse, validate every row (a bad hex or empty CTA must fail loudly, not render a broken ad), and write one props file per variant.
|
||||
|
||||
```js
|
||||
// csv-to-props.js usage: node csv-to-props.js variants.csv ./props [aspect]
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const [, , csvPath, outDir, aspect = "9x16"] = process.argv;
|
||||
const REQUIRED = ["id", "hook", "benefit", "proof", "cta", "accent", "bg"];
|
||||
const isHex = (s) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s);
|
||||
|
||||
const parseCSV = (text) => {
|
||||
const [head, ...lines] = text.trim().split(/\r?\n/);
|
||||
const cols = head.split(",").map((c) => c.trim());
|
||||
return lines.filter(Boolean).map((line) => {
|
||||
// simple split; wrap fields containing commas in double quotes in the CSV
|
||||
const cells = line.match(/("([^"]|"")*"|[^,]*)/g).filter((_, i, a) => i < a.length - 1);
|
||||
const row = {};
|
||||
cols.forEach((c, i) => (row[c] = (cells[i] ?? "").replace(/^"|"$/g, "").replace(/""/g, '"').trim()));
|
||||
return row;
|
||||
});
|
||||
};
|
||||
|
||||
const rows = parseCSV(fs.readFileSync(csvPath, "utf8"));
|
||||
fs.mkdirSync(outDir, {recursive: true});
|
||||
|
||||
const seen = new Set();
|
||||
let errors = 0;
|
||||
rows.forEach((row, n) => {
|
||||
const where = `row ${n + 2} (${row.id || "no-id"})`;
|
||||
REQUIRED.forEach((k) => { if (!row[k]) { console.error(`✗ ${where}: missing "${k}"`); errors++; } });
|
||||
if (seen.has(row.id)) { console.error(`✗ ${where}: duplicate id`); errors++; }
|
||||
seen.add(row.id);
|
||||
if (row.hook && row.hook.length > 60) console.warn(`⚠ ${where}: hook >60 chars may clip in 9:16`);
|
||||
["accent", "bg"].forEach((k) => { if (row[k] && !isHex(row[k])) { console.error(`✗ ${where}: "${k}" not a hex color`); errors++; } });
|
||||
if (errors) return;
|
||||
fs.writeFileSync(path.join(outDir, `${row.id}.json`), JSON.stringify({v: row, aspect}, null, 2));
|
||||
});
|
||||
|
||||
if (errors) { console.error(`\n${errors} error(s) — no broken ads written.`); process.exit(1); }
|
||||
console.log(`✓ ${rows.length} variant prop files written to ${outDir}`);
|
||||
```
|
||||
|
||||
Example `variants.csv` (one fully message-matched ad per row):
|
||||
|
||||
```csv
|
||||
id,hook,benefit,proof,cta,accent,bg
|
||||
v01_problem,Spending 2 hrs/day on reports?,Auto-build them in one click.,Teams save 11 hrs a week.,Save 2 hours — try free,#4F46E5,#FFFFFF
|
||||
v02_interrupt,Your reporting tool is lying to you.,See the real numbers instantly.,Teams save 11 hrs a week.,See the real numbers — free,#DC2626,#0B0B0F
|
||||
v03_curiosity,Nobody talks about this reporting trick.,One click builds the whole report.,Teams save 11 hrs a week.,Try the trick — free,#059669,#FFFFFF
|
||||
```
|
||||
|
||||
Note the CTA mirrors the hook in every row — that is the message-match discipline encoded into the data.
|
||||
|
||||
## 6. Test-matrix generator — one variable at a time
|
||||
|
||||
A test only teaches something if exactly one field changes. This expands a base variant plus a list of values for ONE field into a CSV, so a hook test or a CTA test stays clean and combinatorial mistakes are impossible.
|
||||
|
||||
```js
|
||||
// make-matrix.js usage: node make-matrix.js > variants.csv
|
||||
const base = {benefit: "Auto-build them in one click.", proof: "Teams save 11 hrs a week.",
|
||||
cta: "Save 2 hours — try free", accent: "#4F46E5", bg: "#FFFFFF"};
|
||||
|
||||
// Vary ONLY this field; everything else is held identical across the test.
|
||||
const FIELD = "hook";
|
||||
const values = [
|
||||
"Spending 2 hrs/day on reports?",
|
||||
"Your reporting tool is lying to you.",
|
||||
"Nobody talks about this reporting trick.",
|
||||
"Cut report time in half this week.",
|
||||
"If you build reports by hand, stop.",
|
||||
];
|
||||
|
||||
const esc = (s) => (/[",]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
|
||||
const cols = ["id", "hook", "benefit", "proof", "cta", "accent", "bg"];
|
||||
console.log(cols.join(","));
|
||||
values.forEach((val, i) => {
|
||||
const row = {...base, [FIELD]: val, id: `${FIELD}_${String(i + 1).padStart(2, "0")}`};
|
||||
console.log(cols.map((c) => esc(row[c])).join(","));
|
||||
});
|
||||
```
|
||||
|
||||
To run a CTA test next, set `FIELD = "cta"`, paste the *winning* hook into `base.hook`, and list CTA variants. Same one-variable rule, next stage of the funnel.
|
||||
|
||||
## 7. Batch + multi-aspect render
|
||||
|
||||
Render every variant in every aspect a campaign needs. Concurrency keeps large batches fast.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# render-all.sh one MP4 per (variant × aspect)
|
||||
set -euo pipefail
|
||||
|
||||
node make-matrix.js > variants.csv # 1) build the one-variable test matrix
|
||||
ASPECTS=("9x16" "4x5" "1x1") # placements this campaign needs
|
||||
|
||||
for ar in "${ASPECTS[@]}"; do
|
||||
node csv-to-props.js variants.csv "props/$ar" "$ar" # 2) typed, validated props per aspect
|
||||
for f in "props/$ar"/*.json; do
|
||||
id=$(basename "$f" .json)
|
||||
npx remotion render AdTemplate "out/${id}_${ar}.mp4" \
|
||||
--props="$f" --concurrency=4 --log=error # 3) render
|
||||
done
|
||||
done
|
||||
|
||||
echo "Done. $(ls out/*.mp4 | wc -l) ad variants in ./out"
|
||||
```
|
||||
|
||||
5 hooks × 3 aspects = 15 MP4s from one template and one command. Add a CTA stage and the same template produces the next test wave with zero new component code.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Two variables changed at once** — the winner is uninterpretable. The matrix generator prevents this; keep using it.
|
||||
- **Hook clips in 9:16** — long hooks overflow; the parser warns past 60 chars. Keep hooks short and high in the frame.
|
||||
- **CTA hidden behind platform UI** — anything in the 9:16 bottom third gets covered by captions and the CTA button. The `bottomSafe` padding reserves that band.
|
||||
- **Drift across variants** — accent/copy live in the data, everything else in `theme`. If a variant looks off-brand, the fix is the theme, not the row.
|
||||
- **Animation flicker in export** — caused by CSS transitions or library timers. Every value must derive from `useCurrentFrame()`.
|
||||
|
||||
---
|
||||
## Built by the team behind iart.ai
|
||||
|
||||
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can mass-produce ad-creative variants from one template × a data table — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=ad-video-skills&utm_content=ref_footer&utm_term=ad-creative-video)
|
||||
@@ -1,72 +0,0 @@
|
||||
# Platform Specs & Message-Match Worksheet
|
||||
|
||||
Exact pixel safe zones, durations, aspect ratios, and format limits per placement (2025/2026), plus a worksheet for building message-matched hook→CTA pairs before they go into the CSV. Verify against each platform's current Ads Manager docs before a big spend — placements and safe zones shift.
|
||||
|
||||
## Aspect ratio → placement map
|
||||
|
||||
| Aspect | Resolution | Native placements |
|
||||
|---|---|---|
|
||||
| 9:16 | 1080×1920 | TikTok In-Feed, Instagram/Facebook Reels, Stories, YouTube Shorts |
|
||||
| 4:5 | 1080×1350 | Meta Feed (largest feed real estate, recommended for Feed video) |
|
||||
| 1:1 | 1080×1080 | Meta Feed, broad/automatic placements, X |
|
||||
| 16:9 | 1920×1080 | YouTube in-stream, landscape feed, in-article |
|
||||
|
||||
Master rule: design the hook, product, and CTA inside the **1:1 center square** so a single composition reframes to every aspect by re-centering, not letterboxing. Black bars on a vertical feed read as "ad" and underperform.
|
||||
|
||||
## Safe zones (keep-clear bands)
|
||||
|
||||
Platform UI — captions, CTA chips, usernames, profile icons, progress bars — overlaps the frame. Keep all critical content out of these bands.
|
||||
|
||||
| Aspect / placement | Top keep-clear | Bottom keep-clear | Sides |
|
||||
|---|---|---|---|
|
||||
| 9:16 Reels/Stories (Meta) | ~14% (~270px on 1920h) | ~20–35% (~384–672px) | ~6% (~65px) |
|
||||
| 9:16 TikTok In-Feed | ~130px | ~484px (right-side icon rail + caption) | right ~140px |
|
||||
| 4:5 Meta Feed | ~5% | ~10% (keep CTA above it) | ~5% |
|
||||
| 1:1 / 16:9 | center 90% safe | center 90% safe | center 90% safe |
|
||||
|
||||
The 9:16 bottom third is the single most violated zone — captions, the CTA button, and the handle stack there. The `bottomSafe` padding in `batch-ad-pipeline.md` reserves it.
|
||||
|
||||
## Duration & format limits
|
||||
|
||||
| Placement | Sweet spot | Hard limits | Format |
|
||||
|---|---|---|---|
|
||||
| Meta Feed (4:5/1:1) | 6–15s | up to 240 min, ≤4GB | MP4/MOV, H.264 |
|
||||
| Meta Reels/Stories (9:16) | 8–15s | Reels ≤90s, Stories ≤2 min | MP4/MOV |
|
||||
| TikTok In-Feed (9:16) | 15–30s (TikTok cites 21–34s) | up to 10 min (non-Spark) | MP4/MOV, ≥540×960 |
|
||||
| YouTube in-stream (16:9) | 15–30s | skippable after 5s | MP4 |
|
||||
|
||||
Practical default for performance video: **15–30s, with the hook trigger landing before 2s.** Most ad-account data shows attention falls off a cliff after ~15s, so front-load everything that matters.
|
||||
|
||||
## Hook → CTA message-match worksheet
|
||||
|
||||
Fill one row per ad *before* writing the CSV. The CTA must pay off the exact promise the hook made; if it doesn't, the click is wasted and the test is noisy. Each completed row becomes one record in `variants.csv`.
|
||||
|
||||
| Hook (0–3s promise) | Hook type | Single benefit | Proof point | CTA (pays off the hook) |
|
||||
|---|---|---|---|---|
|
||||
| "Spending 2 hrs/day on reports?" | problem-first | one-click report build | "save 11 hrs/week" | "Save 2 hours — try free" |
|
||||
| "Your reporting tool is lying to you." | pattern interrupt | shows real numbers instantly | "save 11 hrs/week" | "See the real numbers — free" |
|
||||
| "Nobody talks about this reporting trick." | curiosity gap | one click builds it | "save 11 hrs/week" | "Try the trick — free" |
|
||||
| "Cut report time in half this week." | immediate benefit | auto-built reports | "save 11 hrs/week" | "Cut it in half — free" |
|
||||
| "If you build reports by hand, stop." | direct callout | automate it now | "save 11 hrs/week" | "Automate yours — free" |
|
||||
|
||||
Bad pairing to avoid: a "stop wasting 2 hours" hook ending on a generic "Shop now" — the promise and the payoff don't connect, so even a great hook converts poorly.
|
||||
|
||||
## Testing order (which field to vary first)
|
||||
|
||||
Run tests in funnel order; isolate one field per wave using the matrix generator:
|
||||
|
||||
1. **Hook** — biggest lever on CPA; test 5–8 hooks against one identical body.
|
||||
2. **Offer / benefit** — take the winning hook, vary the core promise.
|
||||
3. **CTA** — winning hook + offer, vary the closing action.
|
||||
4. **Format / aspect** — same winning ad, compare placements.
|
||||
|
||||
Budget guide many teams use: ~60% to proven winners, ~30% to variations of winners, ~10% to fresh concepts. Run each test long enough for a real sample (commonly several hundred impressions per variant, ~1 week to smooth daily swings) before declaring a winner.
|
||||
|
||||
## Pre-flight checklist per variant
|
||||
|
||||
- Hook trigger lands before 2s and fits the frame (≤~60 chars in 9:16).
|
||||
- CTA visibly pays off the hook's specific promise.
|
||||
- Critical content inside the center square and outside every keep-clear band.
|
||||
- Duration within the placement's sweet spot.
|
||||
- Exported in every aspect the campaign's placements require.
|
||||
- Exactly one field differs from the other variants in this test wave.
|
||||
Whitespace-only changes.
@@ -1,137 +0,0 @@
|
||||
---
|
||||
name: launch-video
|
||||
description: This skill should be used when the user asks to "make a launch video", "create a product hype/sizzle film", "build a teaser/trailer for a drop", "do a reveal montage with an end-card CTA", "cut a hero video to the beat", or "export a launch film in 16:9/9:16/1:1". Covers the hook→tease→reveal→feature-montage→end-card arc and multi-aspect export.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Launch Video
|
||||
|
||||
Make a premium, high-energy 15–60s launch film that stops the scroll and converts. Build the classic arc — hook → tease → reveal → feature montage → end-card CTA — cut to the music, and export for every platform aspect.
|
||||
|
||||
## When to use
|
||||
|
||||
- Product drops, feature announcements, trailers, hype reels (15–60s).
|
||||
- "Sizzle" hero videos for a landing page.
|
||||
- Beat-synced reveals where the brand moment lands on the drop.
|
||||
|
||||
## The arc
|
||||
|
||||
| Beat | Job | Typical share (of 30s) |
|
||||
|---|---|---|
|
||||
| Hook (0–3s) | A striking frame/motion that stops the scroll | 0–3s |
|
||||
| Tease | Hint the product; build curiosity, beat-synced | 3–9s |
|
||||
| Reveal | Product/logo hits on the music drop | 9–13s |
|
||||
| Feature montage | Fast, kinetic, one benefit per shot | 13–25s |
|
||||
| End card | Logo + tagline + CTA, clean hold | 25–30s |
|
||||
|
||||
Scale the same proportions for 15s (tighter) or 60s (longer montage, never a longer hook).
|
||||
|
||||
## Two non-negotiable rules
|
||||
|
||||
1. **Sound design leads picture.** Lock the track first, mark the beats and the drop, then cut visuals to those marks. Never score a finished cut — the reveal lands on the drop, not near it.
|
||||
2. **Quality over quantity.** A few flawless shots beat many mediocre ones. Cut any shot that isn't premium.
|
||||
|
||||
## Beat-synced reveal on the drop
|
||||
|
||||
Find the drop's exact timecode in the audio, then time the reveal so the brand mark snaps to full at that frame, with a micro-overshoot for impact.
|
||||
|
||||
```js
|
||||
import gsap from "gsap";
|
||||
const DROP = 9.0; // seconds, measured from the track
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
// tease builds tension up to the drop
|
||||
tl.to(".tease", { opacity: 1, scale: 1.05, duration: DROP, ease: "power1.in" })
|
||||
// reveal SNAPS on the drop frame
|
||||
.set(".logo", { opacity: 1 }, DROP)
|
||||
.fromTo(".logo", { scale: 1.18 }, { scale: 1.0, duration: .18, ease: "power3.out" }, DROP)
|
||||
.fromTo(".flash", { opacity: .9 }, { opacity: 0, duration: .25 }, DROP); // white hit
|
||||
audio.addEventListener("play", () => tl.play());
|
||||
```
|
||||
|
||||
Cut on transients, not on a fixed grid: the hardest cuts go on kick/snare hits; ramp speed (time-remap) into the drop, then hard-cut out of it.
|
||||
|
||||
## Kinetic feature montage
|
||||
|
||||
One benefit per shot, ~0.6–1.0s each, hard cuts on the beat. Each shot = a bold keyword + a single supporting visual.
|
||||
|
||||
```css
|
||||
.feature { opacity:0; }
|
||||
.feature.in .kw { animation: pop .35s cubic-bezier(.22,1,.36,1) forwards; }
|
||||
.feature.in .word { display:inline-block; }
|
||||
@keyframes pop { from { opacity:0; transform:translateY(24px) } to { opacity:1; transform:none } }
|
||||
```
|
||||
|
||||
```js
|
||||
// drive shots off beat marks measured from the track
|
||||
const beats = [13.0, 13.8, 14.6, 15.4, 16.2]; // seconds
|
||||
beats.forEach((t, i) => scheduleAt(t, () => showFeature(i)));
|
||||
```
|
||||
|
||||
Keep one motion language across the montage (same enter curve, same exit) so speed reads as confidence, not chaos.
|
||||
|
||||
## End card
|
||||
|
||||
Hold the logo + tagline + CTA still and clean for ≥2s. No busy motion competing with the CTA; let the brand settle. Land the brand moment on the strongest remaining beat, then a crisp button/URL.
|
||||
|
||||
## Multi-aspect export
|
||||
|
||||
Compose with a center safe zone so one master crops cleanly to all aspects.
|
||||
|
||||
| Aspect | Use | Resolution | Safe zone |
|
||||
|---|---|---|---|
|
||||
| 16:9 | YouTube, landing hero, X | 1920×1080 | keep key content within center 90% |
|
||||
| 9:16 | Reels, TikTok, Shorts, Stories | 1080×1920 | text within center 80% h; avoid top 12% / bottom 18% (UI) |
|
||||
| 1:1 | Feed posts | 1080×1080 | center square of the 16:9 frame |
|
||||
|
||||
Design the hero/logo/CTA inside the **1:1 center square** so it survives every crop. Render the 16:9 master, then reframe (don't just letterbox) 9:16 and 1:1 from the same project.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Track locked first; reveal lands exactly on the drop.
|
||||
- Hook earns the first 3 seconds.
|
||||
- Montage: one benefit per shot, hard cuts on beats, consistent motion language.
|
||||
- End card holds ≥2s with one clear CTA.
|
||||
- 16:9 + 9:16 + 1:1 exports, key content in the center safe zone.
|
||||
- Premium-only shots — nothing mediocre survives the cut.
|
||||
|
||||
## Deliver & verify (rendered stills → MP4)
|
||||
|
||||
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
|
||||
|
||||
If the launch film is built/rendered in Remotion, treat it as the heavy tier: register a `<Composition>` (+ zod `schema` + `defaultProps`), drive every value off `useCurrentFrame()` — no GSAP timelines / audio-event callbacks / `Date.now()` / `Math.random()` at render time (bake beat + drop timecodes into props). Deliverable = the rendered `out/*.mp4` per aspect (plus the project).
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Sample the arc, with the **shipped** props (real headline/tagline/CTA), not just `defaultProps`.
|
||||
|
||||
```bash
|
||||
# Frame-exact stills across hook → reveal → end-card
|
||||
npx remotion still Launch out/f-hook.png --frame=24 --props=props/launch.json # hook earns first 3s
|
||||
npx remotion still Launch out/f-reveal.png --frame=270 --props=props/launch.json # logo SNAPS on the drop frame
|
||||
npx remotion still Launch out/f-end.png --frame=899 --props=props/launch.json # end card: logo+tagline+CTA, clean hold
|
||||
# inspect each: fidelity (product/logo, tagline, CTA text exact, brand colors right, reveal at the drop frame)
|
||||
# AND artifacts (text overflow, off-canvas, CTA in the 9:16 bottom third, missing font, flash frame stuck on)
|
||||
```
|
||||
|
||||
**Multi-aspect — verify the master in EACH aspect before encoding all of them.** A reframe bug (hero pushed off the safe zone) repeats across every aspect; catch it once.
|
||||
|
||||
```bash
|
||||
for ar in 16x9 9x16 1x1; do # check reveal + end card per aspect
|
||||
npx remotion still Launch "out/reveal-${ar}.png" --frame=270 \
|
||||
--props=props/launch.json --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done
|
||||
# stills clean in all aspects? → then encode each:
|
||||
for ar in 16x9 9x16 1x1; do
|
||||
npx remotion render Launch "out/launch-${ar}.mp4" --props=props/launch.json --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done
|
||||
npx remotion render Launch out/demo.gif --codec=gif --props=props/launch.json # README demo
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. `npx remotion still` renders cleanly at hook / reveal / end card — no errors, no missing fonts/assets.
|
||||
2. Logo/tagline/CTA text exact, brand colors right; reveal lands on the drop frame; key content inside the center safe zone, clear of the 9:16 bottom third.
|
||||
3. Frame-driven only — no GSAP/audio-callback timing, `Date.now()`, or `Math.random()` (beats/drop baked into props).
|
||||
4. Shipped props render correctly (not just `defaultProps`); the end card holds ≥2s with one CTA.
|
||||
5. Master verified in 16:9 + 9:16 + 1:1 before encoding; MP4s play; (optional) GIF for the README.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/launch-structure.md` — a full shot-by-shot template with timecodes for a 30s film (scalable to 15–60s), beat-synced reveal timing, kinetic-montage shot recipes, the sound-design-leads-picture workflow, and detailed multi-aspect safe-area maps for 16:9 / 9:16 / 1:1.
|
||||
@@ -1,142 +0,0 @@
|
||||
# Launch Video Structure — Cookbook
|
||||
|
||||
Shot-by-shot template, beat-sync timing, montage recipes, and multi-aspect maps for a premium 15–60s launch film.
|
||||
|
||||
## Sound-design-leads-picture workflow
|
||||
|
||||
1. **Pick/commission the track first.** The track defines the film's length and energy.
|
||||
2. **Mark the structure in the audio**: intro, build, the **drop** (the single biggest moment), and the kick/snare transients through the montage. Note exact timecodes.
|
||||
3. **Map beats → cuts.** Hard cuts land on transients; the reveal lands on the drop frame.
|
||||
4. **Build picture to the marks.** Never finish a cut and then look for music. Score-first guarantees the reveal hits.
|
||||
5. **Mix last**: add risers into the drop, an impact/whoosh on the reveal, and let the end-card breathe (drop music low, or a clean tag).
|
||||
|
||||
## Shot-by-shot template — 30s film (scalable)
|
||||
|
||||
```
|
||||
00:00–00:03 HOOK
|
||||
Visual : single striking frame — extreme close detail / impossible motion
|
||||
Motion : slow push-in or a fast whip that resolves; NO logo yet
|
||||
Audio : sub-bass swell begins, sparse
|
||||
Cut : on first audible beat at ~03s
|
||||
|
||||
00:03–00:09 TEASE
|
||||
Visual : fragments/silhouettes of the product, never the full thing
|
||||
Motion : light leaks, partial reveals, parallax; tension rising
|
||||
Audio : riser building toward the drop
|
||||
Cut : quick cuts accelerating into the drop
|
||||
|
||||
00:09–00:13 REVEAL ← THE DROP at 00:09
|
||||
Visual : product/logo SNAPS to full on the drop frame
|
||||
Motion : scale 1.18→1.0 over 0.18s + white flash-out; settle clean
|
||||
Audio : drop hits + impact stinger on the exact frame
|
||||
Hold : let it sit ~1.2s before moving
|
||||
|
||||
00:13–00:25 FEATURE MONTAGE
|
||||
Visual : one benefit per shot, ~0.6–1.0s each (≈8–10 shots)
|
||||
Motion : bold keyword pop + one supporting visual; consistent enter/exit
|
||||
Audio : hard cuts on kick/snare; energy sustained
|
||||
Pattern: KEYWORD + UI/product moment, repeat with rhythm
|
||||
|
||||
00:25–00:30 END CARD
|
||||
Visual : logo + tagline + CTA, centered, still
|
||||
Motion : minimal — a soft settle, then hold ≥2s
|
||||
Audio : music resolves; clean sonic tag
|
||||
CTA : one action (URL or button), nothing competing
|
||||
```
|
||||
|
||||
### Re-proportioning
|
||||
|
||||
- **15s**: Hook 0–2s · Tease 2–5s · Reveal 5–7s · Montage 7–12s (4–5 shots) · End card 12–15s.
|
||||
- **60s**: same hook (still ≤3s) · longer tease and a richer montage (14–16 shots, grouped in waves) · end card ≥3s.
|
||||
|
||||
The hook never gets longer — only the montage scales.
|
||||
|
||||
## Beat-synced reveal (timing detail)
|
||||
|
||||
```js
|
||||
import gsap from "gsap";
|
||||
const DROP = 9.0; // measured drop timecode (s)
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// build tension toward the drop
|
||||
tl.fromTo(".tease", { scale: 1.0, opacity: .6 },
|
||||
{ scale: 1.08, opacity: 1, duration: DROP, ease: "power1.in" });
|
||||
|
||||
// REVEAL exactly on the drop
|
||||
tl.set(".logo", { opacity: 1 }, DROP);
|
||||
tl.fromTo(".logo", { scale: 1.18 }, { scale: 1.0, duration: .18, ease: "power3.out" }, DROP);
|
||||
tl.fromTo(".flash", { opacity: .9 }, { opacity: 0, duration: .25, ease: "power2.out" }, DROP);
|
||||
tl.to(".tease", { opacity: 0, duration: .12 }, DROP);
|
||||
|
||||
// hold the reveal
|
||||
tl.to(".logo", { scale: 1.0, duration: 1.2 }); // dwell
|
||||
|
||||
audio.addEventListener("play", () => tl.play());
|
||||
```
|
||||
|
||||
Speed-ramp into the drop (Remotion time-remap idea): play the last ~0.5s of the tease faster, then hard-cut to the reveal on the drop frame for a snap.
|
||||
|
||||
## Kinetic montage shot recipe
|
||||
|
||||
Each shot: a bold keyword + one product/UI visual, ~0.6–1.0s, hard cut on the beat.
|
||||
|
||||
```css
|
||||
.feature { position:absolute; inset:0; opacity:0; }
|
||||
.feature.in { opacity:1; }
|
||||
.feature .word { display:inline-block; opacity:0; transform:translateY(26px); }
|
||||
.feature.in .word { animation: pop .32s cubic-bezier(.22,1,.36,1) forwards; }
|
||||
.feature.in .word:nth-child(2){ animation-delay:.04s }
|
||||
.feature.in .word:nth-child(3){ animation-delay:.08s }
|
||||
@keyframes pop { to { opacity:1; transform:none; } }
|
||||
```
|
||||
|
||||
```js
|
||||
// schedule shots on measured beat marks
|
||||
const beats = [13.0, 13.8, 14.6, 15.4, 16.2, 17.0, 17.8, 18.6]; // seconds
|
||||
let i = 0;
|
||||
function loop(now){
|
||||
const t = (now - start) / 1000;
|
||||
if (i < beats.length && t >= beats[i]) { showFeature(i); i++; }
|
||||
if (i < beats.length) requestAnimationFrame(loop);
|
||||
}
|
||||
```
|
||||
|
||||
Montage discipline: keep ONE enter curve and ONE exit across all shots; vary content, never grammar. Alternate visual rhythm (full-frame ↔ detail) but keep cut cadence locked to the beat.
|
||||
|
||||
## Multi-aspect safe-area maps
|
||||
|
||||
Compose so a single master reframes to every aspect. Place hero/logo/CTA inside the smallest common safe zone — the 1:1 center square.
|
||||
|
||||
```
|
||||
16:9 1920×1080
|
||||
Safe: center 90% (96px H / 54px V margins)
|
||||
Use : YouTube, landing hero, X timeline
|
||||
|
||||
9:16 1080×1920
|
||||
Safe: text within center 80% height
|
||||
Avoid: top 12% (status/clock area), bottom 18% (caption + UI buttons)
|
||||
Use : Reels, TikTok, Shorts, Stories
|
||||
|
||||
1:1 1080×1080
|
||||
Safe: this square == the design anchor; put logo/CTA here
|
||||
Use : feed posts
|
||||
```
|
||||
|
||||
Export workflow:
|
||||
|
||||
1. Build the 16:9 master with all key content inside the 1:1 center square.
|
||||
2. For 9:16, **reframe** (pan/scale per shot to keep the subject centered) — do not letterbox.
|
||||
3. For 1:1, crop the center square; verify text isn't clipped.
|
||||
4. Re-check captions/CTA against each platform's UI overlay zones before export.
|
||||
|
||||
## Final QA checklist
|
||||
|
||||
- [ ] Reveal lands on the exact drop frame, with overshoot + flash.
|
||||
- [ ] Hook holds attention in the first 3s without showing the product.
|
||||
- [ ] Every montage shot is one benefit, premium quality, on a beat.
|
||||
- [ ] End card holds ≥2s, single CTA, nothing competing.
|
||||
- [ ] 16:9 / 9:16 / 1:1 each pass safe-area and caption checks.
|
||||
- [ ] Audio mixed: riser → impact on drop → clean tag on end card.
|
||||
|
||||
---
|
||||
Nail the reveal beat and the sound-leads-picture cut, and a launch film lands. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=ad-video-skills&utm_content=skill_footer&utm_term=launch-video)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -1,196 +0,0 @@
|
||||
---
|
||||
name: testimonial-video
|
||||
description: This skill should be used when the user asks to "make an animated testimonial", "create a quote video", "build a review video", "turn a customer testimonial into a graphic", "animate a customer review", "make a social proof video", "add an animated star rating", or "turn a list of reviews into videos". Covers quote typography, staggered line reveals, kinetic emphasis on key phrases, animated star ratings, the author block (name/role/avatar/company), and templating one card across many quotes.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Testimonial Video
|
||||
|
||||
Animate a written quote, review, or testimonial into a short, trustworthy social-proof clip. The craft is typographic, not flashy: a clean quote card where the words land in reading order, the key phrase gets emphasis exactly when it should, the stars fill to the real score, and the author signs off. One card design, driven by props, becomes a video for every review on the list.
|
||||
|
||||
## When to use
|
||||
|
||||
- Customer review → a 6–12s vertical/feed clip (the headline use case).
|
||||
- A pull-quote or testimonial graphic with author name, role, avatar, company.
|
||||
- An animated star rating (including half/fractional scores like 4.5).
|
||||
- A batch: a list/CSV of reviews → one video each, same template.
|
||||
|
||||
Do **not** reach here for sale/discount promos or generic ad creative — this skill is strictly the quote / social-proof angle.
|
||||
|
||||
## The one rule: trust before motion
|
||||
|
||||
A testimonial earns its job by feeling *real*, not produced. Restraint reads as credibility; over-animation reads as an ad people skip.
|
||||
|
||||
1. **Reveal in reading order.** The eye must finish a line before the next arrives. Stagger lines (or clauses), never all words at once, and never reorder for visual flair.
|
||||
2. **Quote the words exactly.** Do not paraphrase, trim mid-sentence, or invent a rating. If you emphasize a phrase, it must be the customer's phrase.
|
||||
3. **The author is the proof.** A quote with no attributable human is just a slogan. Name + role + company (and avatar if real) is non-negotiable.
|
||||
|
||||
## The anatomy of a quote card
|
||||
|
||||
| Element | Job | Timing |
|
||||
|---|---|---|
|
||||
| Quote mark motif | Signals "this is a quote" before words read | Frame 0, scales/fades in first |
|
||||
| Quote body | The testimonial, revealed line-by-line | Staggered, ~6–10 frames apart |
|
||||
| Emphasis span | The one phrase that sells | Lands *after* its line settles |
|
||||
| Star rating | The score, filled to the real value | Fills left→right after the quote |
|
||||
| Author block | Name / role / company / avatar | Signs off last, slides up |
|
||||
|
||||
Build in that order; reveal in that order. The viewer reads the quote, believes it, sees the score, then learns who said it.
|
||||
|
||||
## Staggered line reveal
|
||||
|
||||
Split the quote into lines (or clauses), then offset each one's entrance. In Remotion, drive every line off `useCurrentFrame()` so it renders deterministically — never a CSS transition or a JS timer, which desync from the frame renderer.
|
||||
|
||||
```jsx
|
||||
import { useCurrentFrame, spring, useVideoConfig } from "remotion";
|
||||
|
||||
const QuoteBody = ({ lines, startAt = 8, stagger = 6 }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
return (
|
||||
<blockquote>
|
||||
{lines.map((line, i) => {
|
||||
const enter = spring({ frame: frame - startAt - i * stagger, fps,
|
||||
config: { damping: 200 } }); // 0→1, no overshoot
|
||||
return (
|
||||
<span key={i} style={{ display: "block",
|
||||
opacity: enter,
|
||||
transform: `translateY(${(1 - enter) * 14}px)` }}>
|
||||
{line}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</blockquote>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Keep one motion language: same easing, same 14px rise, every line. Speed reads as confidence only when it's consistent. See `references/quote-card.md` for the full composition.
|
||||
|
||||
## Kinetic emphasis on the key phrase
|
||||
|
||||
Pick the single phrase that carries the testimonial ("saved us 10 hours a week") and let it arrive *after* its line has settled — a highlight sweep, a weight shift, or a color pop. Emphasis competing with the reveal cancels both.
|
||||
|
||||
```jsx
|
||||
const sweep = interpolate(frame, [hlStart, hlStart + 10], [0, 100],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
// a highlighter wiping in behind the phrase, left→right
|
||||
<mark style={{ background:
|
||||
`linear-gradient(90deg, var(--accent) ${sweep}%, transparent ${sweep}%)`,
|
||||
color: "inherit", padding: "0 .08em" }}>{phrase}</mark>
|
||||
```
|
||||
|
||||
Emphasize at most one span per quote. Two highlights is no highlight.
|
||||
|
||||
## Animated star rating (fractional)
|
||||
|
||||
Render the real score, including halves. Use one full row of star glyphs as a mask, then wipe a filled layer to `rating / max` width — this handles 4.5 as cleanly as 5.0, with no half-star sprite.
|
||||
|
||||
```jsx
|
||||
const StarRating = ({ rating, max = 5, fillStart = 30, fillDur = 14 }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const target = (rating / max) * 100;
|
||||
const w = interpolate(frame, [fillStart, fillStart + fillDur], [0, target],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
return (
|
||||
<div className="stars" role="img" aria-label={`${rating} out of ${max} stars`}>
|
||||
<div className="track">★★★★★</div>
|
||||
<div className="fill" style={{ width: `${w}%` }}>★★★★★</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
```css
|
||||
.stars { position: relative; display: inline-block; font-size: 40px; letter-spacing: 4px; }
|
||||
.track { color: rgba(255,255,255,.22); }
|
||||
.fill { position: absolute; inset: 0; overflow: hidden; white-space: nowrap;
|
||||
color: var(--accent); } /* clipped by width → fractional fill */
|
||||
```
|
||||
|
||||
Fill left→right after the quote reads. Never animate stars before the words — the score must feel earned, not pre-loaded. Always set `aria-label` to the real value.
|
||||
|
||||
## The author block
|
||||
|
||||
Sign off last. The block slides up under the stars and holds still — stillness is what makes attribution read as fact.
|
||||
|
||||
```jsx
|
||||
<figcaption className="author">
|
||||
{avatar && <img src={avatar} alt="" className="avatar" />}
|
||||
<div>
|
||||
<span className="name">{name}</span>
|
||||
<span className="role">{role}{company && `, ${company}`}</span>
|
||||
</div>
|
||||
</figcaption>
|
||||
```
|
||||
|
||||
Use a real avatar or none — a generic placeholder face actively destroys trust. Name is the loudest line in the block; role/company is secondary weight.
|
||||
|
||||
## Template × reviews — one video per quote
|
||||
|
||||
The payoff: make every field a prop, hardcode nothing, then render once per review.
|
||||
|
||||
```jsx
|
||||
export const Testimonial = ({ quote, highlight, rating, name, role, company, avatar }) => { /* … */ };
|
||||
```
|
||||
```bash
|
||||
for f in reviews/*.json; do
|
||||
npx remotion render Testimonial "out/$(basename "$f" .json).mp4" --props="$f"
|
||||
done
|
||||
```
|
||||
|
||||
Keep colors/fonts/quote-mark/layout in one theme object so 50 testimonials stay brand-locked and only the words and score change. See `references/batch-and-typography.md` for the review schema, CSV→props parsing, quote-typography craft (smart quotes, hanging punctuation, line-splitting, orphans), and the batch script.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Lines reveal in reading order; one consistent enter motion, ~6–10 frame stagger.
|
||||
- The quote is verbatim; exactly one emphasized span, landing after its line settles.
|
||||
- Stars fill to the *real* score (halves supported) after the quote, with a correct `aria-label`.
|
||||
- Author block (name + role + company, real avatar or none) signs off last and holds still.
|
||||
- Every animated value derives from `useCurrentFrame()` — no CSS/JS timers.
|
||||
- Every field is a prop; one template renders every review in the folder.
|
||||
|
||||
## Deliver & verify (rendered stills → MP4)
|
||||
|
||||
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
|
||||
|
||||
**Output contract:**
|
||||
- A Remotion `Testimonial` registered as `<Composition>` (+ zod `schema` + `defaultProps`), every value frame-driven (no CSS transitions / JS timers / `Date.now()` / `Math.random()`).
|
||||
- Deliverable = the rendered `out/*.mp4` per review per aspect (plus the project + review data, so the user re-renders on new quotes).
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Cheap PNGs first; render with the **shipped** review's props, not just `defaultProps`.
|
||||
|
||||
```bash
|
||||
# Frame-exact stills across the reveal order: quote → stars → author
|
||||
npx remotion still Testimonial out/f-quote.png --frame=30 --props=reviews/r1.json # lines in reading order, emphasis span
|
||||
npx remotion still Testimonial out/f-stars.png --frame=48 --props=reviews/r1.json # stars filled to the REAL score (e.g. 4.5)
|
||||
npx remotion still Testimonial out/f-author.png --frame=215 --props=reviews/r1.json # author block signed off, holding still
|
||||
# inspect each: fidelity (quote VERBATIM, exactly one emphasized span, star fill matches rating, name/role/company exact)
|
||||
# AND artifacts (text overflow/orphans, off-canvas, author in the 9:16 bottom third, missing font, broken avatar)
|
||||
```
|
||||
|
||||
**Multi-aspect / batch — verify one review in EACH target aspect before batch-rendering the list.** A layout bug (overflowing quote, clipped author) repeats across every review × aspect; catch it once.
|
||||
|
||||
```bash
|
||||
for ar in 9x16 1x1 16x9; do # one representative review, every aspect
|
||||
npx remotion still Testimonial "out/r1-${ar}.png" --frame=215 \
|
||||
--props=reviews/r1.json --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done
|
||||
# stills clean in all aspects? → then batch-render the list:
|
||||
for f in reviews/*.json; do id=$(basename "$f" .json); for ar in 9x16 1x1 16x9; do
|
||||
npx remotion render Testimonial "out/${id}-${ar}.mp4" --props="$f" --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done; done
|
||||
npx remotion render Testimonial out/demo.gif --codec=gif --props=reviews/r1.json # README demo
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. `npx remotion still` renders cleanly at quote / stars / author — no errors, no missing fonts/avatar.
|
||||
2. Quote is verbatim with one emphasized span; stars fill to the real score; name/role/company exact and inside the safe zone (clear of the 9:16 bottom third).
|
||||
3. Frame-driven only — no CSS/JS timers, `Date.now()`, or `Math.random()`.
|
||||
4. Shipped review's props render correctly (not just `defaultProps`); `aria-label` carries the real rating.
|
||||
5. One review verified in 9:16 + 1:1 + 16:9 before the batch; MP4s play; (optional) GIF for the README.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/quote-card.md` — a complete runnable Remotion `Testimonial` composition: staggered line reveal, key-phrase highlight sweep, fractional star fill, author block, theme object, and the timing map for a 9:16 card. Plus a dependency-free HTML/CSS variant.
|
||||
- `references/batch-and-typography.md` — the review prop schema, CSV/JSON→props parsing and validation, quote typography craft (smart quotes, hanging quotation marks, line/clause splitting, orphan control, legibility in motion), star-rating accessibility, and the template×reviews batch render script for N videos.
|
||||
@@ -1,140 +0,0 @@
|
||||
# Batch & Typography
|
||||
|
||||
How to feed many reviews into one card design, plus the quote-typography craft that separates a credible testimonial from a sloppy one.
|
||||
|
||||
## Review prop schema
|
||||
|
||||
One object per testimonial. Keep it flat so a CSV row maps to it directly.
|
||||
|
||||
```ts
|
||||
type Review = {
|
||||
quote: string; // verbatim — never paraphrased or trimmed mid-sentence
|
||||
highlight?: string; // EXACT substring of `quote`; one phrase only
|
||||
rating: number; // 0–max, halves allowed (4.5)
|
||||
max?: number; // default 5
|
||||
name: string; // required — a quote with no name is not social proof
|
||||
role?: string;
|
||||
company?: string;
|
||||
avatar?: string; // real photo URL, or omit (never a stock face)
|
||||
};
|
||||
```
|
||||
|
||||
### Validation (fail loud, before rendering 50 videos)
|
||||
|
||||
```ts
|
||||
function validate(r: Review): string[] {
|
||||
const errs: string[] = [];
|
||||
if (!r.quote?.trim()) errs.push("quote is empty");
|
||||
if (!r.name?.trim()) errs.push("name is required (no anonymous testimonials)");
|
||||
const max = r.max ?? 5;
|
||||
if (r.rating < 0 || r.rating > max) errs.push(`rating ${r.rating} out of 0–${max}`);
|
||||
if (r.highlight && !r.quote.includes(r.highlight))
|
||||
errs.push(`highlight "${r.highlight}" is not a substring of the quote`);
|
||||
return errs;
|
||||
}
|
||||
```
|
||||
|
||||
The `highlight ⊂ quote` check is the important one: it guarantees you only ever emphasize words the customer actually wrote. If the highlight isn't an exact substring, the emphasis silently disappears in the card — so block the render instead.
|
||||
|
||||
## CSV → props
|
||||
|
||||
Reviews usually arrive as a spreadsheet export. Map columns to the schema and coerce the rating.
|
||||
|
||||
```js
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
|
||||
// CSV columns: quote,highlight,rating,name,role,company,avatar
|
||||
const rows = parse(readFileSync("reviews.csv"), { columns: true, trim: true });
|
||||
mkdirSync("reviews", { recursive: true });
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
const r = {
|
||||
quote: row.quote,
|
||||
highlight: row.highlight || undefined,
|
||||
rating: Number(row.rating),
|
||||
name: row.name,
|
||||
role: row.role || undefined,
|
||||
company: row.company || undefined,
|
||||
avatar: row.avatar || undefined,
|
||||
};
|
||||
const errs = validate(r);
|
||||
if (errs.length) { console.error(`Row ${i + 1} (${r.name}):`, errs); process.exit(1); }
|
||||
writeFileSync(`reviews/${String(i).padStart(3, "0")}-${slug(r.name)}.json`,
|
||||
JSON.stringify(r, null, 2));
|
||||
});
|
||||
|
||||
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
```
|
||||
|
||||
## Batch render — one video per review
|
||||
|
||||
```bash
|
||||
mkdir -p out
|
||||
for f in reviews/*.json; do
|
||||
name=$(basename "$f" .json)
|
||||
npx remotion render Testimonial "out/$name.mp4" --props="$f"
|
||||
done
|
||||
```
|
||||
|
||||
Everything visual lives in the single `theme` object in the composition (colors, fonts, quote-mark glyph, spacing). Because only the props change per render, 50 testimonials stay pixel-for-pixel brand-locked while the words and score vary. To re-theme the whole set, edit one object and re-run the loop.
|
||||
|
||||
For multi-aspect (9:16 feed + 1:1 post + 16:9 site), register the composition at each size with a shared center-safe layout and loop over both `reviews/*.json` and the aspect list.
|
||||
|
||||
## Quote typography craft
|
||||
|
||||
Motion is the easy part; legible, well-set type is what makes the quote trustworthy.
|
||||
|
||||
### Smart quotes, always
|
||||
|
||||
Straight quotes (`"`) read as un-proofed. Convert to typographic quotes before rendering.
|
||||
|
||||
```js
|
||||
const smarten = (s) => s
|
||||
.replace(/(^|[\s([{])"/g, "$1“").replace(/"/g, "”") // “ ”
|
||||
.replace(/(^|[\s([{])'/g, "$1‘").replace(/'/g, "’") // ‘ ’
|
||||
.replace(/--/g, "—"); // em dash
|
||||
```
|
||||
|
||||
### Hanging quotation marks
|
||||
|
||||
A leading quote glyph indents the first line and breaks the flush-left edge. Hang it into the margin so the text block stays optically aligned — the gold standard for set quotes.
|
||||
|
||||
```css
|
||||
blockquote { hanging-punctuation: first; } /* Safari/WebKit */
|
||||
/* Cross-engine fallback: pull the opening mark left by its own width */
|
||||
blockquote::before { content: "\201C"; margin-left: -0.55em; color: var(--muted); }
|
||||
```
|
||||
|
||||
When the card uses a large decorative quote mark (as in `quote-card.md`), drop the inline opening glyph so the mark isn't doubled.
|
||||
|
||||
### Splitting into lines / clauses
|
||||
|
||||
Reveal in reading order means splitting on meaning, not on a fixed character count. Prefer clause boundaries (`. ! ? , ; —`), then pack to a comfortable measure (~32–40 chars at card size). The `toLines` helper in `quote-card.md` does exactly this. Never split a short quote into one-word-per-line — that reads as a ransom note, not a testimonial.
|
||||
|
||||
### Orphans & widows
|
||||
|
||||
A single dangling word on the last line looks broken. Bind the last two words with a non-breaking space so they wrap together.
|
||||
|
||||
```js
|
||||
const noOrphan = (s) => s.replace(/ ([^ ]+)\s*$/, " $1");
|
||||
```
|
||||
|
||||
### Legibility in motion
|
||||
|
||||
- Use a font that stays readable while moving — avoid thin weights and tight tracking; a serif or a humanist sans at 500–600 weight holds up best on a rising line.
|
||||
- Animate **opacity + a small translate** only. Blur, rotation, and scale on body text hurt readability mid-reveal.
|
||||
- Contrast: body text ≥ 4.5:1 against the card; the highlight accent should pass against both the card and the ink it sits behind.
|
||||
- One emphasized span per quote. A second highlight halves the weight of the first.
|
||||
|
||||
## Star-rating accessibility & correctness
|
||||
|
||||
- The visual stars are decorative; expose the value to assistive tech with `role="img"` and `aria-label="4.5 out of 5 stars"` on the container (already in `quote-card.md`).
|
||||
- For fractional scores, the width-clip method (`rating / max * 100%`) is exact for any decimal — no rounding to the nearest half-star, so 4.3 renders as 4.3.
|
||||
- Never animate the stars before the quote is read, and never show more filled stars than the data supports. A faked rating is the fastest way to lose the trust the testimonial was meant to build.
|
||||
- If a review has no rating, omit the star row entirely rather than defaulting to 5 — an invented score is worse than none.
|
||||
|
||||
## Pacing notes
|
||||
|
||||
- Hold the finished card still for the back half of the clip (≥3s) so it's screenshot-able and re-shareable — shares are the whole point of social proof.
|
||||
- For a sequence of testimonials in one video, give each card a full read (~3–4s) and hard-cut between them; cross-dissolving quotes makes both unreadable.
|
||||
@@ -1,241 +0,0 @@
|
||||
# Quote Card — complete runnable component
|
||||
|
||||
A full testimonial card you can drop into a Remotion project, plus a zero-dependency HTML/CSS variant for non-Remotion pipelines. Both reveal in reading order, emphasize one phrase, fill stars to the real score, and sign off with the author.
|
||||
|
||||
## Timing map (9:16, 30fps, ~10s)
|
||||
|
||||
| Frame | Beat |
|
||||
|---|---|
|
||||
| 0–8 | Quote mark scales in; card settles |
|
||||
| 8–48 | Quote body, line-by-line (8 + i×6) |
|
||||
| 44–54 | Key-phrase highlight sweep (after its line settles) |
|
||||
| 56–70 | Stars fill left→right to `rating/max` |
|
||||
| 72–88 | Author block slides up, holds |
|
||||
| 88–300 | Everything holds still (screenshot-able) |
|
||||
|
||||
The card holds motionless for the back half — stillness is what makes a testimonial read as a fact rather than an ad.
|
||||
|
||||
## Remotion composition
|
||||
|
||||
```tsx
|
||||
import {
|
||||
AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate, Img,
|
||||
} from "remotion";
|
||||
|
||||
export type Review = {
|
||||
quote: string; // verbatim testimonial
|
||||
highlight?: string; // exact substring of `quote` to emphasize
|
||||
rating: number; // e.g. 4.5
|
||||
max?: number; // default 5
|
||||
name: string;
|
||||
role?: string;
|
||||
company?: string;
|
||||
avatar?: string; // real photo URL, or omit
|
||||
};
|
||||
|
||||
const theme = {
|
||||
bg: "#0E1116",
|
||||
ink: "#F5F7FA",
|
||||
muted: "rgba(245,247,250,.55)",
|
||||
accent: "#FFC24B",
|
||||
font: "Inter, system-ui, sans-serif",
|
||||
quoteFont: "'Newsreader', Georgia, serif",
|
||||
quoteMark: "“", // left double quotation mark
|
||||
};
|
||||
|
||||
// Split a quote into clauses for reading-order reveal.
|
||||
// Prefer sentence/clause boundaries; fall back to ~32-char wraps.
|
||||
const toLines = (q: string): string[] => {
|
||||
const parts = q.match(/[^.!?,;]+[.!?,;]?\s*/g) ?? [q];
|
||||
const lines: string[] = [];
|
||||
let buf = "";
|
||||
for (const p of parts) {
|
||||
if ((buf + p).length > 34 && buf) { lines.push(buf.trim()); buf = p; }
|
||||
else buf += p;
|
||||
}
|
||||
if (buf.trim()) lines.push(buf.trim());
|
||||
return lines;
|
||||
};
|
||||
|
||||
// Render a line, wrapping the highlight substring in <mark> if present.
|
||||
const Line: React.FC<{ text: string; highlight?: string; sweep: number }> = ({
|
||||
text, highlight, sweep,
|
||||
}) => {
|
||||
if (!highlight || !text.includes(highlight)) return <>{text}</>;
|
||||
const [before, after] = text.split(highlight);
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<mark
|
||||
style={{
|
||||
background: `linear-gradient(90deg, ${theme.accent} ${sweep}%, transparent ${sweep}%)`,
|
||||
color: "inherit",
|
||||
padding: "0 .08em",
|
||||
borderRadius: 3,
|
||||
WebkitBoxDecorationBreak: "clone",
|
||||
}}
|
||||
>
|
||||
{highlight}
|
||||
</mark>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Testimonial: React.FC<Review> = ({
|
||||
quote, highlight, rating, max = 5, name, role, company, avatar,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const lines = toLines(quote);
|
||||
|
||||
// Quote mark
|
||||
const markIn = spring({ frame, fps, config: { damping: 14 } });
|
||||
|
||||
// Highlight sweep, after the body has revealed
|
||||
const hlStart = 8 + lines.length * 6 + 4;
|
||||
const sweep = interpolate(frame, [hlStart, hlStart + 10], [0, 100], {
|
||||
extrapolateLeft: "clamp", extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Stars fill, after the quote
|
||||
const fillStart = hlStart + 14;
|
||||
const target = (rating / max) * 100;
|
||||
const starW = interpolate(frame, [fillStart, fillStart + 14], [0, target], {
|
||||
extrapolateLeft: "clamp", extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Author block slides up last
|
||||
const authStart = fillStart + 16;
|
||||
const auth = spring({ frame: frame - authStart, fps, config: { damping: 200 } });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: theme.bg, fontFamily: theme.font,
|
||||
padding: 120, justifyContent: "center", color: theme.ink }}>
|
||||
<div
|
||||
aria-hidden
|
||||
style={{ fontFamily: theme.quoteFont, fontSize: 200, lineHeight: .6,
|
||||
color: theme.accent, opacity: markIn, transform: `scale(${markIn})`,
|
||||
transformOrigin: "left top" }}
|
||||
>
|
||||
{theme.quoteMark}
|
||||
</div>
|
||||
|
||||
<blockquote style={{ fontFamily: theme.quoteFont, fontSize: 64,
|
||||
lineHeight: 1.25, margin: "24px 0 48px", fontWeight: 500 }}>
|
||||
{lines.map((line, i) => {
|
||||
const enter = spring({ frame: frame - (8 + i * 6), fps,
|
||||
config: { damping: 200 } });
|
||||
return (
|
||||
<span key={i} style={{ display: "block", opacity: enter,
|
||||
transform: `translateY(${(1 - enter) * 14}px)` }}>
|
||||
<Line text={line} highlight={highlight} sweep={sweep} />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</blockquote>
|
||||
|
||||
{/* Stars */}
|
||||
<div role="img" aria-label={`${rating} out of ${max} stars`}
|
||||
style={{ position: "relative", display: "inline-block", fontSize: 52,
|
||||
letterSpacing: 6, marginBottom: 56 }}>
|
||||
<div style={{ color: "rgba(245,247,250,.20)" }}>{"★".repeat(max)}</div>
|
||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden",
|
||||
whiteSpace: "nowrap", width: `${starW}%`, color: theme.accent }}>
|
||||
{"★".repeat(max)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Author */}
|
||||
<figcaption style={{ display: "flex", alignItems: "center", gap: 20,
|
||||
opacity: auth, transform: `translateY(${(1 - auth) * 16}px)` }}>
|
||||
{avatar && (
|
||||
<Img src={avatar} alt=""
|
||||
style={{ width: 88, height: 88, borderRadius: "50%", objectFit: "cover" }} />
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<span style={{ fontSize: 38, fontWeight: 700 }}>{name}</span>
|
||||
<span style={{ fontSize: 30, color: theme.muted }}>
|
||||
{[role, company].filter(Boolean).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
</figcaption>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Register it with a duration long enough for the back-half hold:
|
||||
|
||||
```tsx
|
||||
<Composition id="Testimonial" component={Testimonial}
|
||||
durationInFrames={300} fps={30} width={1080} height={1920}
|
||||
defaultProps={{
|
||||
quote: "We shipped in days, not months. Onboarding saved us 10 hours a week.",
|
||||
highlight: "saved us 10 hours a week",
|
||||
rating: 4.5, name: "Dana Reyes", role: "Head of Ops", company: "Northwind",
|
||||
}} />
|
||||
```
|
||||
|
||||
## Why these choices
|
||||
|
||||
- **`spring` with `damping: 200`** gives a clean rise with no overshoot — testimonials should feel composed, not bouncy. The quote mark uses lighter damping (14) for a touch of life on the one decorative element.
|
||||
- **Highlight as a gradient `<mark>`** wipes a real highlighter behind the phrase without a second element or a mask, and survives line wraps via `box-decoration-break: clone`.
|
||||
- **Stars as two stacked glyph rows clipped by width** render any fractional score (4.5, 4.3) with no half-star asset, and the filled row inherits the brand accent.
|
||||
- **Author after stars** matches reading psychology: claim → proof → who. Reordering weakens trust.
|
||||
|
||||
## Zero-dependency HTML/CSS variant
|
||||
|
||||
For pipelines that capture a headless-browser timeline (or just a web embed). Animations are CSS `@keyframes` with per-line `animation-delay`; if rendering frame-exact video, prefer the Remotion version above so frames stay deterministic.
|
||||
|
||||
```html
|
||||
<figure class="card" style="--accent:#FFC24B; --rating:4.5; --max:5">
|
||||
<div class="qmark" aria-hidden="true">“</div>
|
||||
<blockquote>
|
||||
<span style="--i:0">We shipped in days, not months.</span>
|
||||
<span style="--i:1">Onboarding <mark>saved us 10 hours a week.</mark></span>
|
||||
</blockquote>
|
||||
<div class="stars" role="img" aria-label="4.5 out of 5 stars">
|
||||
<div class="track">★★★★★</div><div class="fill">★★★★★</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<img src="dana.jpg" alt="" class="avatar">
|
||||
<div><b>Dana Reyes</b><small>Head of Ops, Northwind</small></div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
```
|
||||
|
||||
```css
|
||||
.card { --bg:#0E1116; --ink:#F5F7FA; background:var(--bg); color:var(--ink);
|
||||
font-family:Inter,system-ui,sans-serif; padding:120px; max-width:1080px; }
|
||||
.qmark { font:200px/.6 'Newsreader',Georgia,serif; color:var(--accent);
|
||||
animation:pop .4s cubic-bezier(.22,1,.36,1) both; }
|
||||
blockquote { font:500 64px/1.25 'Newsreader',Georgia,serif; margin:24px 0 48px; }
|
||||
blockquote span { display:block; opacity:0;
|
||||
animation:rise .5s cubic-bezier(.22,1,.36,1) both;
|
||||
animation-delay:calc(.27s + var(--i) * .2s); }
|
||||
mark { background:linear-gradient(90deg,var(--accent) 100%,transparent 100%);
|
||||
color:inherit; padding:0 .08em; border-radius:3px;
|
||||
-webkit-box-decoration-break:clone; box-decoration-break:clone;
|
||||
animation:sweep .35s ease-out both; animation-delay:1s; }
|
||||
@keyframes sweep { from { background-size:0 100% } to { background-size:100% 100% } }
|
||||
.stars { position:relative; display:inline-block; font-size:52px; letter-spacing:6px; }
|
||||
.track { color:rgba(245,247,250,.20); }
|
||||
.fill { position:absolute; inset:0; overflow:hidden; white-space:nowrap;
|
||||
color:var(--accent); width:0; animation:fill .5s ease-out 1.3s forwards; }
|
||||
@keyframes fill { to { width:calc(var(--rating) / var(--max) * 100%) } }
|
||||
figcaption { display:flex; align-items:center; gap:20px; margin-top:56px;
|
||||
opacity:0; animation:rise .5s ease-out 1.8s both; }
|
||||
.avatar { width:88px; height:88px; border-radius:50%; object-fit:cover; }
|
||||
figcaption b { font-size:38px; } figcaption small { display:block; font-size:30px;
|
||||
color:rgba(245,247,250,.55); }
|
||||
@keyframes pop { from { opacity:0; transform:scale(.7) } to { opacity:1; transform:none } }
|
||||
@keyframes rise { from { opacity:0; transform:translateY(14px) } to { opacity:1; transform:none } }
|
||||
```
|
||||
|
||||
The CSS `mark` sweep uses `background-size` (animatable) rather than a gradient color-stop, since color-stop percentages don't animate in CSS. The star `--rating/--max` math is the same fractional fill as the Remotion version.
|
||||
|
||||
---
|
||||
## Built by the team behind iart.ai
|
||||
|
||||
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can turn a list of reviews/quotes into testimonial videos, swap text, and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=ad-video-skills&utm_content=ref_footer&utm_term=testimonial-video)
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/ad-creative-video
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/launch-video
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,62 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,38 +0,0 @@
|
||||
# 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>`.
|
||||
@@ -1,37 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,34 +0,0 @@
|
||||
# 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>
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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).
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/testimonial-video
|
||||
@@ -0,0 +1,12 @@
|
||||
# Copy to .env style settings on your host. Never commit real values.
|
||||
ADMIN_EMAIL=rana.krunal7558@gmail.com
|
||||
ADMIN_PASSWORD=pick-a-long-password-here
|
||||
# Where new tickets are emailed (this is already the default)
|
||||
TICKET_TO=rana.krunal7558@gmail.com
|
||||
# Gmail sending: use an "App Password", not your normal password
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=rana.krunal7558@gmail.com
|
||||
SMTP_PASS=your-16-letter-app-password
|
||||
# Set to 1 when hosted behind a proxy (most hosts)
|
||||
TRUST_PROXY=1
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
server/data
|
||||
.env
|
||||
@@ -0,0 +1,25 @@
|
||||
# Deploy checklist (Hostinger)
|
||||
|
||||
## A. Before you start
|
||||
- [ ] Plan is Business Web Hosting or a Cloud plan (Node.js Web App supported)
|
||||
- [ ] Gmail app password created (myaccount.google.com > Security > 2-Step Verification > App passwords)
|
||||
|
||||
## B. In hPanel: Websites > Add website > Node.js Web App > Import Git repository
|
||||
- [ ] Repo: keithrana/cycle-atlas
|
||||
- [ ] Branch: claude/sleepy-goodall-rykli1 (NOT main)
|
||||
- [ ] Root/app directory: it-support (if no such box, ask Claude to split into its own repo)
|
||||
- [ ] Node version: 22.x or 24.x
|
||||
- [ ] Build command: npm run build
|
||||
- [ ] Start command: npm start (entry file, if asked: server/index.js)
|
||||
|
||||
## C. Environment variables (type them only into Hostinger, never into chat or Git)
|
||||
ADMIN_EMAIL, ADMIN_PASSWORD (10+ chars), SMTP_HOST=smtp.gmail.com, SMTP_PORT=465,
|
||||
SMTP_USER, SMTP_PASS (Gmail app password), TRUST_PROXY=1, DATA_DIR=/home/<your-username>/helpdesk-data
|
||||
|
||||
## D. Deploy, then test in order
|
||||
1. [ ] Page loads over https:// and the video plays
|
||||
2. [ ] Search "wifi" shows answers; send a test ticket
|
||||
3. [ ] Email arrives at rana.krunal7558@gmail.com (check Spam)
|
||||
4. [ ] /#/staff login works and the ticket is listed
|
||||
5. [ ] Add a test FAQ, redeploy, confirm it is still there (proves data is kept)
|
||||
6. [ ] Log out; /#/staff asks for login again
|
||||
@@ -0,0 +1,18 @@
|
||||
# IT Support Site: PRD (plan)
|
||||
|
||||
Restaurant analogy: the FAQ database is the **kitchen's recipe book**. The website is the **dining room**.
|
||||
|
||||
## Core features
|
||||
1. Cinematic hero: looping video, glass buttons (exactly as specified)
|
||||
2. Search bar in the hero: type a problem, get matching answers
|
||||
3. Category chips: browse the 11 topics (Access, Network, Hardware...)
|
||||
4. FAQ answer cards: question, answer, optional "official guide" link
|
||||
5. Footer social icons
|
||||
|
||||
## Phased roadmap
|
||||
- **Phase 1 – Food stand (TODAY):** hero + search + categories + 87 FAQs from the database, baked in as a JSON file. No server.
|
||||
- **Phase 2 – Small restaurant (DONE, browser-only storage):** "Was this helpful?" votes, "Contact IT" ticket form, shareable links per question.
|
||||
- **Phase 3 – Full restaurant (DONE):** small back-of-house server + real database, staff login, admin page to edit FAQs, ticket tracking.
|
||||
|
||||
## Test ladder
|
||||
1. Page loads, no console errors 2. Buttons/search work 3. Real data/links work 4. Security (no raw HTML injection, safe links)
|
||||
@@ -0,0 +1,11 @@
|
||||
# HelpDesk IT Support
|
||||
|
||||
Run locally (needs Node 22+):
|
||||
npm install
|
||||
npm run build
|
||||
ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='a-long-password' npm start
|
||||
Open http://localhost:3001 (staff login: http://localhost:3001/#/staff)
|
||||
|
||||
For development with live reload: run `npm run server` and `npm run dev` in two terminals.
|
||||
|
||||
Settings are listed in `.env.example`. Data lives in `server/data/helpdesk.db`: back it up, and keep it on a persistent disk when hosting.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HelpDesk | IT Support</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3528
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "it-support",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"server": "node server/index.js",
|
||||
"start": "NODE_ENV=production node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.22.3",
|
||||
"lucide-react": "^0.460.0",
|
||||
"nodemailer": "^10.0.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||
@@ -0,0 +1,48 @@
|
||||
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto'
|
||||
import { db } from './db.js'
|
||||
|
||||
export const SESSION_DAYS = 7
|
||||
export const hashPassword = (pw) => {
|
||||
const salt = randomBytes(16)
|
||||
return `${salt.toString('hex')}:${scryptSync(pw, salt, 64).toString('hex')}`
|
||||
}
|
||||
export const verifyPassword = (pw, stored) => {
|
||||
const [salt, hash] = stored.split(':')
|
||||
const a = Buffer.from(hash, 'hex')
|
||||
const b = scryptSync(pw, Buffer.from(salt, 'hex'), 64)
|
||||
return a.length === b.length && timingSafeEqual(a, b)
|
||||
}
|
||||
const sha = (t) => createHash('sha256').update(t).digest('hex')
|
||||
|
||||
export function createSession(staffId) {
|
||||
const token = randomBytes(32).toString('hex')
|
||||
db.prepare('INSERT INTO sessions VALUES (?,?,?)').run(sha(token), staffId, Date.now() + SESSION_DAYS * 864e5)
|
||||
return token
|
||||
}
|
||||
export const destroySession = (token) => db.prepare('DELETE FROM sessions WHERE token_hash=?').run(sha(token))
|
||||
|
||||
function cookieToken(req) {
|
||||
const m = /(?:^|;\s*)session=([a-f0-9]{64})/.exec(req.headers.cookie || '')
|
||||
return m ? m[1] : null
|
||||
}
|
||||
export { cookieToken }
|
||||
|
||||
export function requireStaff(req, res, next) {
|
||||
const token = cookieToken(req)
|
||||
const row = token && db.prepare('SELECT s.id, s.email, s.name, x.expires FROM sessions x JOIN staff s ON s.id=x.staff_id WHERE x.token_hash=?').get(sha(token))
|
||||
if (!row || row.expires < Date.now()) return res.status(401).json({ error: 'Please log in.' })
|
||||
req.staff = row
|
||||
next()
|
||||
}
|
||||
|
||||
// Tiny in-memory rate limiter: `max` hits per `windowMs` per IP+bucket.
|
||||
const hits = new Map()
|
||||
export const rateLimit = (bucket, max, windowMs) => (req, res, next) => {
|
||||
const key = `${bucket}:${req.ip}`
|
||||
const now = Date.now()
|
||||
const recent = (hits.get(key) || []).filter((t) => now - t < windowMs)
|
||||
if (recent.length >= max) return res.status(429).json({ error: 'Too many tries. Please wait a bit.' })
|
||||
recent.push(now)
|
||||
hits.set(key, recent)
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { readFileSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const dataDir = process.env.DATA_DIR || join(here, 'data')
|
||||
mkdirSync(dataDir, { recursive: true })
|
||||
|
||||
export const db = new DatabaseSync(join(dataDir, 'helpdesk.db'))
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS faqs (id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL, question TEXT NOT NULL, answer TEXT NOT NULL, link TEXT NOT NULL DEFAULT '', link_label TEXT NOT NULL DEFAULT '');
|
||||
CREATE TABLE IF NOT EXISTS tickets (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, topic TEXT NOT NULL, message TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', emailed INTEGER NOT NULL DEFAULT 0, created TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS votes (id INTEGER PRIMARY KEY AUTOINCREMENT, faq_id INTEGER NOT NULL, vote TEXT NOT NULL, created TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS staff (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, password_hash TEXT NOT NULL, created TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, staff_id INTEGER NOT NULL, expires INTEGER NOT NULL);
|
||||
`)
|
||||
|
||||
// First run: copy the 87 FAQs from the bundled file into the real database.
|
||||
if (db.prepare('SELECT COUNT(*) AS n FROM faqs').get().n === 0) {
|
||||
const seed = JSON.parse(readFileSync(join(here, '..', 'src', 'data', 'faqs.json'), 'utf8'))
|
||||
const ins = db.prepare('INSERT INTO faqs (id, category, question, answer, link, link_label) VALUES (?,?,?,?,?,?)')
|
||||
for (const f of seed) ins.run(f.id, f.category, f.question, f.answer, f.link, f.linkLabel)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import express from 'express'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { db } from './db.js'
|
||||
import { hashPassword, verifyPassword, createSession, destroySession, cookieToken, requireStaff, rateLimit, SESSION_DAYS } from './auth.js'
|
||||
import { sendTicketEmail, emailConfigured } from './mail.js'
|
||||
|
||||
const app = express()
|
||||
const prod = process.env.NODE_ENV === 'production'
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const clean = (s, max) => String(s ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, max)
|
||||
const cleanBlock = (s, max) => String(s ?? '').trim().slice(0, max)
|
||||
const isHttpUrl = (u) => { try { return ['http:', 'https:'].includes(new URL(u).protocol) } catch { return false } }
|
||||
|
||||
app.set('trust proxy', process.env.TRUST_PROXY ? 1 : false)
|
||||
app.disable('x-powered-by')
|
||||
app.use((req, res, next) => {
|
||||
res.set({
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Referrer-Policy': 'same-origin',
|
||||
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; media-src https://d8j0ntlcm91z4.cloudfront.net; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'",
|
||||
})
|
||||
next()
|
||||
})
|
||||
app.use(express.json({ limit: '20kb' }))
|
||||
// Block cross-site writes: browsers always send Origin on POST/PUT/PATCH/DELETE from other sites.
|
||||
app.use((req, res, next) => {
|
||||
if (['GET', 'HEAD'].includes(req.method)) return next()
|
||||
const origin = req.headers.origin
|
||||
if (origin && new URL(origin).host !== req.headers.host) return res.status(403).json({ error: 'Bad origin.' })
|
||||
next()
|
||||
})
|
||||
|
||||
// ---------- Public ----------
|
||||
const faqOut = (r) => ({ id: r.id, category: r.category, question: r.question, answer: r.answer, link: r.link, linkLabel: r.link_label })
|
||||
app.get('/api/faqs', (_req, res) => res.json(db.prepare('SELECT * FROM faqs ORDER BY id').all().map(faqOut)))
|
||||
|
||||
app.post('/api/tickets', rateLimit('ticket', 5, 3600e3), async (req, res) => {
|
||||
const t = {
|
||||
id: `IT-${Date.now().toString(36).toUpperCase()}${randomBytes(2).toString('hex').toUpperCase()}`,
|
||||
name: clean(req.body.name, 100), email: clean(req.body.email, 200),
|
||||
topic: clean(req.body.topic, 100), message: cleanBlock(req.body.message, 2000),
|
||||
}
|
||||
if (!t.name) return res.status(400).json({ error: 'Please enter your name.' })
|
||||
if (!EMAIL_RE.test(t.email)) return res.status(400).json({ error: 'Please enter a valid email.' })
|
||||
if (t.message.length < 10) return res.status(400).json({ error: 'Please describe the problem (at least 10 characters).' })
|
||||
db.prepare('INSERT INTO tickets (id,name,email,topic,message,created) VALUES (?,?,?,?,?,?)').run(t.id, t.name, t.email, t.topic, t.message, new Date().toISOString())
|
||||
const emailed = await sendTicketEmail(t)
|
||||
if (emailed) db.prepare('UPDATE tickets SET emailed=1 WHERE id=?').run(t.id)
|
||||
res.status(201).json({ id: t.id })
|
||||
})
|
||||
|
||||
app.post('/api/votes', rateLimit('vote', 30, 3600e3), (req, res) => {
|
||||
const faqId = Number(req.body.faqId)
|
||||
const vote = req.body.vote
|
||||
if (!['up', 'down'].includes(vote) || !db.prepare('SELECT 1 FROM faqs WHERE id=?').get(faqId)) return res.status(400).json({ error: 'Bad vote.' })
|
||||
db.prepare('INSERT INTO votes (faq_id,vote,created) VALUES (?,?,?)').run(faqId, vote, new Date().toISOString())
|
||||
res.status(201).json({ ok: true })
|
||||
})
|
||||
|
||||
// ---------- Staff login ----------
|
||||
app.post('/api/login', rateLimit('login', 10, 15 * 60e3), (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM staff WHERE email=?').get(clean(req.body.email, 200).toLowerCase())
|
||||
// Same message either way so attackers can't learn which emails exist.
|
||||
if (!row || !verifyPassword(String(req.body.password ?? ''), row.password_hash)) return res.status(401).json({ error: 'Wrong email or password.' })
|
||||
const token = createSession(row.id)
|
||||
res.cookie('session', token, { httpOnly: true, sameSite: 'strict', secure: prod, maxAge: SESSION_DAYS * 864e5, path: '/' })
|
||||
res.json({ name: row.name, email: row.email })
|
||||
})
|
||||
app.post('/api/logout', (req, res) => {
|
||||
const t = cookieToken(req)
|
||||
if (t) destroySession(t)
|
||||
res.clearCookie('session', { path: '/' })
|
||||
res.json({ ok: true })
|
||||
})
|
||||
app.get('/api/me', requireStaff, (req, res) => res.json({ name: req.staff.name, email: req.staff.email }))
|
||||
|
||||
// ---------- Staff only ----------
|
||||
app.get('/api/staff/status', requireStaff, (_req, res) => res.json({ emailConfigured }))
|
||||
app.get('/api/staff/tickets', requireStaff, (_req, res) => res.json(db.prepare('SELECT * FROM tickets ORDER BY created DESC').all()))
|
||||
app.patch('/api/staff/tickets/:id', requireStaff, (req, res) => {
|
||||
if (!['open', 'in_progress', 'closed'].includes(req.body.status)) return res.status(400).json({ error: 'Bad status.' })
|
||||
db.prepare('UPDATE tickets SET status=? WHERE id=?').run(req.body.status, req.params.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/staff/faqs', requireStaff, (_req, res) => {
|
||||
const votes = db.prepare("SELECT faq_id, SUM(vote='up') AS up, SUM(vote='down') AS down FROM votes GROUP BY faq_id").all()
|
||||
const byId = new Map(votes.map((v) => [v.faq_id, v]))
|
||||
res.json(db.prepare('SELECT * FROM faqs ORDER BY id').all().map((r) => ({ ...faqOut(r), up: byId.get(r.id)?.up ?? 0, down: byId.get(r.id)?.down ?? 0 })))
|
||||
})
|
||||
function faqFields(body) {
|
||||
const f = { category: clean(body.category, 100), question: clean(body.question, 300), answer: cleanBlock(body.answer, 3000), link: clean(body.link, 500), link_label: clean(body.linkLabel, 200) }
|
||||
if (!f.category || !f.question || !f.answer) return { error: 'Category, question and answer are required.' }
|
||||
if (f.link && !isHttpUrl(f.link)) return { error: 'Link must start with http:// or https://' }
|
||||
return { f }
|
||||
}
|
||||
app.post('/api/staff/faqs', requireStaff, (req, res) => {
|
||||
const { f, error } = faqFields(req.body)
|
||||
if (error) return res.status(400).json({ error })
|
||||
const r = db.prepare('INSERT INTO faqs (category,question,answer,link,link_label) VALUES (?,?,?,?,?)').run(f.category, f.question, f.answer, f.link, f.link_label)
|
||||
res.status(201).json({ id: Number(r.lastInsertRowid) })
|
||||
})
|
||||
app.put('/api/staff/faqs/:id', requireStaff, (req, res) => {
|
||||
const { f, error } = faqFields(req.body)
|
||||
if (error) return res.status(400).json({ error })
|
||||
db.prepare('UPDATE faqs SET category=?,question=?,answer=?,link=?,link_label=? WHERE id=?').run(f.category, f.question, f.answer, f.link, f.link_label, req.params.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
app.delete('/api/staff/faqs/:id', requireStaff, (req, res) => {
|
||||
db.prepare('DELETE FROM faqs WHERE id=?').run(req.params.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/staff/team', requireStaff, (_req, res) => res.json(db.prepare('SELECT id,email,name,created FROM staff ORDER BY id').all()))
|
||||
app.post('/api/staff/team', requireStaff, (req, res) => {
|
||||
const email = clean(req.body.email, 200).toLowerCase(), name = clean(req.body.name, 100), pw = String(req.body.password ?? '')
|
||||
if (!EMAIL_RE.test(email) || !name) return res.status(400).json({ error: 'Name and a valid email are required.' })
|
||||
if (pw.length < 10) return res.status(400).json({ error: 'Password must be at least 10 characters.' })
|
||||
if (db.prepare('SELECT 1 FROM staff WHERE email=?').get(email)) return res.status(409).json({ error: 'That email is already on the team.' })
|
||||
db.prepare('INSERT INTO staff (email,name,password_hash,created) VALUES (?,?,?,?)').run(email, name, hashPassword(pw), new Date().toISOString())
|
||||
res.status(201).json({ ok: true })
|
||||
})
|
||||
app.delete('/api/staff/team/:id', requireStaff, (req, res) => {
|
||||
if (Number(req.params.id) === req.staff.id) return res.status(400).json({ error: "You can't remove yourself." })
|
||||
db.prepare('DELETE FROM sessions WHERE staff_id=?').run(req.params.id)
|
||||
db.prepare('DELETE FROM staff WHERE id=?').run(req.params.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.use('/api', (_req, res) => res.status(404).json({ error: 'Not found.' }))
|
||||
|
||||
// ---------- First staff member ----------
|
||||
if (db.prepare('SELECT COUNT(*) AS n FROM staff').get().n === 0) {
|
||||
const { ADMIN_EMAIL, ADMIN_PASSWORD } = process.env
|
||||
if (ADMIN_EMAIL && ADMIN_PASSWORD && ADMIN_PASSWORD.length >= 10) {
|
||||
db.prepare('INSERT INTO staff (email,name,password_hash,created) VALUES (?,?,?,?)').run(ADMIN_EMAIL.toLowerCase(), 'Admin', hashPassword(ADMIN_PASSWORD), new Date().toISOString())
|
||||
console.log(`Created first staff login: ${ADMIN_EMAIL}`)
|
||||
} else console.warn('No staff yet. Set ADMIN_EMAIL and ADMIN_PASSWORD (10+ characters) and restart.')
|
||||
}
|
||||
if (!emailConfigured) console.warn('Email not configured (SMTP_HOST/SMTP_USER/SMTP_PASS). Tickets are saved but not emailed.')
|
||||
|
||||
// ---------- Serve the website (production) ----------
|
||||
const dist = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist')
|
||||
if (existsSync(dist)) app.use(express.static(dist))
|
||||
|
||||
const port = Number(process.env.PORT || 3001)
|
||||
app.listen(port, () => console.log(`HelpDesk server on http://localhost:${port}`))
|
||||
@@ -0,0 +1,25 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, TICKET_TO = 'rana.krunal7558@gmail.com' } = process.env
|
||||
const transporter = SMTP_HOST && SMTP_USER && SMTP_PASS
|
||||
? nodemailer.createTransport({ host: SMTP_HOST, port: Number(SMTP_PORT || 465), secure: Number(SMTP_PORT || 465) === 465, auth: { user: SMTP_USER, pass: SMTP_PASS } })
|
||||
: null
|
||||
|
||||
export const emailConfigured = !!transporter
|
||||
|
||||
export async function sendTicketEmail(t) {
|
||||
if (!transporter) return false
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: SMTP_USER,
|
||||
to: TICKET_TO,
|
||||
replyTo: t.email,
|
||||
subject: `[${t.id}] ${t.topic}: new IT request from ${t.name}`,
|
||||
text: `Ticket: ${t.id}\nFrom: ${t.name} <${t.email}>\nTopic: ${t.topic}\n\n${t.message}\n`,
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Email failed:', e.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowRight, Globe, Instagram, Twitter } from 'lucide-react'
|
||||
import VideoBackground from './VideoBackground'
|
||||
import FaqList from './Faq'
|
||||
import ContactForm from './ContactForm'
|
||||
import type { Faq } from './types'
|
||||
import Staff from './Staff'
|
||||
import faqData from './data/faqs.json'
|
||||
|
||||
const bundledFaqs = faqData as Faq[]
|
||||
|
||||
export default function App() {
|
||||
const [faqs, setFaqs] = useState<Faq[]>(bundledFaqs)
|
||||
const [isStaff, setIsStaff] = useState(location.hash.startsWith('#/staff'))
|
||||
const categories = useMemo(() => Array.from(new Set(faqs.map((f) => f.category))), [faqs])
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/faqs').then((r) => (r.ok ? r.json() : Promise.reject())).then(setFaqs).catch(() => {})
|
||||
const onHash = () => setIsStaff(location.hash.startsWith('#/staff'))
|
||||
window.addEventListener('hashchange', onHash)
|
||||
return () => window.removeEventListener('hashchange', onHash)
|
||||
}, [])
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [category, setCategory] = useState<string | null>(null)
|
||||
const answersRef = useRef<HTMLElement>(null)
|
||||
|
||||
const results = useMemo(() => {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
return faqs.filter((f) => {
|
||||
if (category && f.category !== category) return false
|
||||
const text = `${f.question} ${f.answer} ${f.category}`.toLowerCase()
|
||||
return words.every((w) => text.includes(w))
|
||||
})
|
||||
}, [query, category, faqs])
|
||||
|
||||
if (isStaff) return <Staff />
|
||||
|
||||
const scrollToAnswers = () => answersRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
|
||||
return (
|
||||
<div className="bg-black">
|
||||
<div className="min-h-screen bg-black overflow-hidden relative flex flex-col">
|
||||
<VideoBackground />
|
||||
|
||||
<nav className="relative z-20 pl-6 pr-6 py-6">
|
||||
<div className="liquid-glass rounded-full px-6 py-3 flex items-center justify-between max-w-5xl mx-auto">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex items-center gap-2 text-white font-semibold text-lg">
|
||||
<Globe size={24} />
|
||||
<span>HelpDesk</span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
{[
|
||||
['Topics', '#answers'],
|
||||
['Search', '#top'],
|
||||
['Contact', '#contact'],
|
||||
].map(([label, href]) => (
|
||||
<a key={label} href={href} className="text-white/80 hover:text-white transition-colors text-sm font-medium">
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="#contact" className="text-white text-sm font-medium">Contact IT</a>
|
||||
<a href="#/staff" className="liquid-glass rounded-full px-6 py-2 text-white text-sm font-medium">Staff login</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
id="top"
|
||||
className="relative z-10 flex-1 flex flex-col items-center justify-center px-6 py-12 text-center -translate-y-[20%]"
|
||||
>
|
||||
<h1
|
||||
className="text-5xl md:text-6xl lg:text-7xl text-white mb-8 tracking-tight whitespace-nowrap"
|
||||
style={{ fontFamily: "'Instrument Serif', serif" }}
|
||||
>
|
||||
IT help, <em>made simple</em>
|
||||
</h1>
|
||||
<div className="max-w-xl w-full space-y-4">
|
||||
<form
|
||||
className="liquid-glass rounded-full pl-6 pr-2 py-2 flex items-center gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
scrollToAnswers()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
maxLength={200}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Describe your problem"
|
||||
aria-label="Search IT help"
|
||||
className="bg-transparent outline-none flex-1 text-white placeholder:text-white/40 text-base"
|
||||
/>
|
||||
<button type="submit" aria-label="Search" className="bg-white rounded-full p-3 text-black">
|
||||
<ArrowRight size={20} />
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-white text-sm leading-relaxed px-4">
|
||||
Locked out? Wi-Fi down? Printer acting up? Search {faqs.length} plain-English answers to the most common IT problems.
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={scrollToAnswers}
|
||||
className="liquid-glass rounded-full px-8 py-3 text-white text-sm font-medium hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Browse all topics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex justify-center gap-4 pb-12">
|
||||
{[
|
||||
{ Icon: Instagram, label: 'Instagram' },
|
||||
{ Icon: Twitter, label: 'Twitter' },
|
||||
{ Icon: Globe, label: 'Website' },
|
||||
].map(({ Icon, label }) => (
|
||||
<button
|
||||
key={label}
|
||||
aria-label={label}
|
||||
className="liquid-glass rounded-full p-4 text-white/80 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
<Icon size={20} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="answers" ref={answersRef} className="max-w-3xl mx-auto px-6 py-20">
|
||||
<h2 className="text-4xl text-white mb-2" style={{ fontFamily: "'Instrument Serif', serif" }}>
|
||||
Answers
|
||||
</h2>
|
||||
<p className="text-white/60 text-sm mb-6" aria-live="polite">
|
||||
Showing {results.length} of {faqs.length}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
{[null, ...categories].map((c) => (
|
||||
<button
|
||||
key={c ?? 'all'}
|
||||
onClick={() => setCategory(c)}
|
||||
aria-pressed={category === c}
|
||||
className={`liquid-glass rounded-full px-4 py-2 text-sm transition-colors ${
|
||||
category === c ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{c ?? 'All'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FaqList items={results} />
|
||||
</section>
|
||||
|
||||
<ContactForm topics={categories} />
|
||||
|
||||
<footer id="about" className="max-w-3xl mx-auto px-6 pb-16 text-center text-white/50 text-sm">
|
||||
General guidance only. For account-specific problems, contact your organisation's IT service desk.
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react'
|
||||
import { Send } from 'lucide-react'
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const inputCls = 'w-full bg-transparent outline-none text-white placeholder:text-white/40 text-base'
|
||||
|
||||
export default function ContactForm({ topics }: { topics: string[] }) {
|
||||
const [form, setForm] = useState({ name: '', email: '', topic: topics[0] ?? '', message: '' })
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [ticketId, setTicketId] = useState<string | null>(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [serverError, setServerError] = useState('')
|
||||
|
||||
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
|
||||
setForm({ ...form, [k]: e.target.value })
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const errs: Record<string, string> = {}
|
||||
if (!form.name.trim()) errs.name = 'Please enter your name.'
|
||||
if (!EMAIL_RE.test(form.email.trim())) errs.email = 'Please enter a valid email.'
|
||||
if (form.message.trim().length < 10) errs.message = 'Please describe the problem (at least 10 characters).'
|
||||
setErrors(errs)
|
||||
setServerError('')
|
||||
if (Object.keys(errs).length) return
|
||||
setSending(true)
|
||||
try {
|
||||
const res = await fetch('/api/tickets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) })
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(data.error || 'Something went wrong. Please try again.')
|
||||
setTicketId(data.id)
|
||||
setForm({ name: '', email: '', topic: topics[0] ?? '', message: '' })
|
||||
} catch (err) {
|
||||
setServerError(err instanceof Error ? err.message : 'Could not reach the server.')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const field = 'liquid-glass rounded-3xl px-6 py-4'
|
||||
const err = (k: string) => errors[k] && <p role="alert" className="text-red-300 text-sm mt-1 px-2">{errors[k]}</p>
|
||||
|
||||
return (
|
||||
<section id="contact" className="max-w-3xl mx-auto px-6 pb-20">
|
||||
<h2 className="text-4xl text-white mb-2" style={{ fontFamily: "'Instrument Serif', serif" }}>Contact IT</h2>
|
||||
<p className="text-white/60 text-sm mb-6">Can't find your answer? Tell us what's wrong.</p>
|
||||
{ticketId ? (
|
||||
<div className="liquid-glass rounded-3xl p-8 text-white" role="status">
|
||||
<p className="text-xl mb-2">Request sent. Your ticket number is <strong>{ticketId}</strong>.</p>
|
||||
<p className="text-white/70 text-sm mb-4">Our IT team has been notified. Keep this number if you follow up.</p>
|
||||
<button onClick={() => setTicketId(null)} className="liquid-glass rounded-full px-6 py-2 text-sm hover:bg-white/5">Send another</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} noValidate className="space-y-3">
|
||||
<div>
|
||||
<div className={field}><input className={inputCls} placeholder="Your name" aria-label="Your name" maxLength={100} value={form.name} onChange={set('name')} /></div>
|
||||
{err('name')}
|
||||
</div>
|
||||
<div>
|
||||
<div className={field}><input className={inputCls} type="email" placeholder="Your email" aria-label="Your email" maxLength={200} value={form.email} onChange={set('email')} /></div>
|
||||
{err('email')}
|
||||
</div>
|
||||
<div className={field}>
|
||||
<select className={`${inputCls} [&>option]:text-black`} aria-label="Topic" value={form.topic} onChange={set('topic')}>
|
||||
{topics.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div className={field}><textarea className={`${inputCls} resize-none`} rows={5} placeholder="What's the problem?" aria-label="Problem description" maxLength={2000} value={form.message} onChange={set('message')} /></div>
|
||||
{err('message')}
|
||||
</div>
|
||||
{serverError && <p role="alert" className="text-red-300 text-sm px-2">{serverError}</p>}
|
||||
<button type="submit" disabled={sending} className="bg-white text-black rounded-full px-8 py-3 text-sm font-medium inline-flex items-center gap-2 disabled:opacity-60">
|
||||
<Send size={16} /> {sending ? 'Sending...' : 'Send request'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, ExternalLink, Link2, SearchX, ThumbsDown, ThumbsUp } from 'lucide-react'
|
||||
import type { Faq } from './types'
|
||||
import { load, save } from './storage'
|
||||
|
||||
type Votes = Record<number, 'up' | 'down'>
|
||||
|
||||
function safeUrl(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function FaqCard({ f, forceOpen, vote, onVote }: { f: Faq; forceOpen: boolean; vote?: 'up' | 'down'; onVote: (v: 'up' | 'down') => void }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const href = f.link ? safeUrl(f.link) : null
|
||||
|
||||
const copyLink = async () => {
|
||||
const url = `${location.origin}${location.pathname}#faq-${f.id}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
location.hash = `faq-${f.id}`
|
||||
}
|
||||
}
|
||||
|
||||
const voteBtn = (v: 'up' | 'down', label: string, Icon: typeof ThumbsUp) => (
|
||||
<button
|
||||
onClick={() => onVote(v)}
|
||||
aria-pressed={vote === v}
|
||||
className={`liquid-glass rounded-full px-4 py-2 inline-flex items-center gap-2 text-sm transition-colors ${
|
||||
vote === v ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} /> {label}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<li id={`faq-${f.id}`} className="scroll-mt-6">
|
||||
<details className="liquid-glass rounded-3xl group" open={forceOpen || undefined}>
|
||||
<summary className="cursor-pointer list-none px-6 py-5 flex items-start justify-between gap-4 text-white">
|
||||
<span>
|
||||
<span className="block text-xs uppercase tracking-wider text-white/50 mb-1">{f.category}</span>
|
||||
<span className="text-lg">{f.question}</span>
|
||||
</span>
|
||||
<span className="text-white/60 transition-transform group-open:rotate-45 text-2xl leading-none">+</span>
|
||||
</summary>
|
||||
<div className="px-6 pb-6 text-white/80 text-sm leading-relaxed">
|
||||
<p>{f.answer}</p>
|
||||
{href && (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 mt-4 text-white hover:underline">
|
||||
{f.linkLabel || 'Official guide'} <ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||
<span className="text-white/50 mr-1">Was this helpful?</span>
|
||||
{voteBtn('up', 'Yes', ThumbsUp)}
|
||||
{voteBtn('down', 'No', ThumbsDown)}
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="liquid-glass rounded-full px-4 py-2 inline-flex items-center gap-2 text-sm text-white/70 hover:text-white hover:bg-white/5 transition-colors ml-auto"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Link2 size={14} />} {copied ? 'Link copied' : 'Copy link'}
|
||||
</button>
|
||||
</div>
|
||||
{vote === 'down' && (
|
||||
<p className="mt-3 text-white/60">
|
||||
Sorry about that. <a href="#contact" className="text-white underline">Contact IT</a> and we'll help directly.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FaqList({ items }: { items: Faq[] }) {
|
||||
const [votes, setVotes] = useState<Votes>(() => load<Votes>('faq-votes', {}))
|
||||
const [hashId, setHashId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const read = () => {
|
||||
const m = /^#faq-(\d+)$/.exec(location.hash)
|
||||
setHashId(m ? Number(m[1]) : null)
|
||||
if (m) window.setTimeout(() => document.getElementById(`faq-${m[1]}`)?.scrollIntoView({ behavior: 'smooth' }), 50)
|
||||
}
|
||||
read()
|
||||
window.addEventListener('hashchange', read)
|
||||
return () => window.removeEventListener('hashchange', read)
|
||||
}, [])
|
||||
|
||||
const onVote = (id: number, v: 'up' | 'down') => {
|
||||
const next = { ...votes, [id]: v }
|
||||
setVotes(next)
|
||||
save('faq-votes', next)
|
||||
fetch('/api/votes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ faqId: id, vote: v }) }).catch(() => {})
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="liquid-glass rounded-3xl p-10 text-center text-white/70">
|
||||
<SearchX className="mx-auto mb-3" size={28} />
|
||||
<p>No answers found. Try different words, or <a href="#contact" className="text-white underline">contact IT</a>.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{items.map((f) => (
|
||||
<FaqCard key={f.id} f={f} forceOpen={hashId === f.id} vote={votes[f.id]} onVote={(v) => onVote(f.id, v)} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { LogOut } from 'lucide-react'
|
||||
|
||||
interface Ticket { id: string; name: string; email: string; topic: string; message: string; status: string; emailed: number; created: string }
|
||||
interface StaffFaq { id: number; category: string; question: string; answer: string; link: string; linkLabel: string; up: number; down: number }
|
||||
interface Member { id: number; email: string; name: string }
|
||||
type Tab = 'tickets' | 'faqs' | 'team'
|
||||
|
||||
async function api<T = unknown>(path: string, method = 'GET', body?: unknown): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined })
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status })
|
||||
return data as T
|
||||
}
|
||||
|
||||
const box = 'liquid-glass rounded-3xl p-6 text-white'
|
||||
const input = 'w-full bg-white/5 rounded-2xl px-4 py-3 text-white placeholder:text-white/40 outline-none'
|
||||
const btn = 'liquid-glass rounded-full px-5 py-2 text-sm text-white hover:bg-white/5 transition-colors'
|
||||
|
||||
function Login({ onDone }: { onDone: () => void }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try { await api('/login', 'POST', { email, password }); onDone() } catch (err) { setError((err as Error).message) }
|
||||
}
|
||||
return (
|
||||
<form onSubmit={submit} className={`${box} max-w-sm mx-auto space-y-3`}>
|
||||
<h1 className="text-3xl mb-2" style={{ fontFamily: "'Instrument Serif', serif" }}>Staff login</h1>
|
||||
<input className={input} type="email" placeholder="Email" aria-label="Email" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="username" />
|
||||
<input className={input} type="password" placeholder="Password" aria-label="Password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
|
||||
{error && <p role="alert" className="text-red-300 text-sm">{error}</p>}
|
||||
<button type="submit" className="bg-white text-black rounded-full px-6 py-2 text-sm font-medium">Log in</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function Tickets() {
|
||||
const [rows, setRows] = useState<Ticket[]>([])
|
||||
const load = useCallback(() => api<Ticket[]>('/staff/tickets').then(setRows), [])
|
||||
useEffect(() => { load() }, [load])
|
||||
const setStatus = async (id: string, status: string) => { await api(`/staff/tickets/${id}`, 'PATCH', { status }); load() }
|
||||
if (!rows.length) return <p className="text-white/60">No tickets yet.</p>
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{rows.map((t) => (
|
||||
<li key={t.id} className={box}>
|
||||
<div className="flex flex-wrap justify-between gap-2 text-sm text-white/60">
|
||||
<span>{t.id} · {t.topic} · {new Date(t.created).toLocaleString()}</span>
|
||||
<span>{t.emailed ? 'emailed' : 'not emailed'}</span>
|
||||
</div>
|
||||
<p className="mt-2 whitespace-pre-wrap">{t.message}</p>
|
||||
<p className="mt-2 text-sm text-white/70">{t.name} · <a className="underline" href={`mailto:${encodeURIComponent(t.email)}`}>{t.email}</a></p>
|
||||
<select aria-label="Status" value={t.status} onChange={(e) => setStatus(t.id, e.target.value)} className="mt-3 bg-white/10 rounded-full px-4 py-2 text-sm [&>option]:text-black">
|
||||
<option value="open">Open</option><option value="in_progress">In progress</option><option value="closed">Closed</option>
|
||||
</select>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyFaq = { category: '', question: '', answer: '', link: '', linkLabel: '' }
|
||||
function Faqs() {
|
||||
const [rows, setRows] = useState<StaffFaq[]>([])
|
||||
const [editing, setEditing] = useState<(typeof emptyFaq & { id?: number }) | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const load = useCallback(() => api<StaffFaq[]>('/staff/faqs').then(setRows), [])
|
||||
useEffect(() => { load() }, [load])
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!editing) return
|
||||
try {
|
||||
if (editing.id) await api(`/staff/faqs/${editing.id}`, 'PUT', editing)
|
||||
else await api('/staff/faqs', 'POST', editing)
|
||||
setEditing(null); setError(''); load()
|
||||
} catch (err) { setError((err as Error).message) }
|
||||
}
|
||||
const remove = async (f: StaffFaq) => { if (confirm(`Delete "${f.question}"?`)) { await api(`/staff/faqs/${f.id}`, 'DELETE'); load() } }
|
||||
const set = (k: keyof typeof emptyFaq) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => editing && setEditing({ ...editing, [k]: e.target.value })
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{editing ? (
|
||||
<form onSubmit={save} className={`${box} space-y-3`}>
|
||||
<input className={input} placeholder="Category" aria-label="Category" value={editing.category} onChange={set('category')} />
|
||||
<input className={input} placeholder="Question" aria-label="Question" value={editing.question} onChange={set('question')} />
|
||||
<textarea className={input} rows={5} placeholder="Answer" aria-label="Answer" value={editing.answer} onChange={set('answer')} />
|
||||
<input className={input} placeholder="Link (optional, https://...)" aria-label="Link" value={editing.link} onChange={set('link')} />
|
||||
<input className={input} placeholder="Link label (optional)" aria-label="Link label" value={editing.linkLabel} onChange={set('linkLabel')} />
|
||||
{error && <p role="alert" className="text-red-300 text-sm">{error}</p>}
|
||||
<div className="flex gap-2"><button type="submit" className="bg-white text-black rounded-full px-6 py-2 text-sm font-medium">Save</button><button type="button" className={btn} onClick={() => { setEditing(null); setError('') }}>Cancel</button></div>
|
||||
</form>
|
||||
) : <button className={btn} onClick={() => setEditing({ ...emptyFaq })}>+ Add FAQ</button>}
|
||||
<ul className="space-y-2">
|
||||
{rows.map((f) => (
|
||||
<li key={f.id} className={`${box} flex flex-wrap items-center justify-between gap-3 !py-4`}>
|
||||
<span><span className="block text-xs text-white/50">{f.category}</span>{f.question}</span>
|
||||
<span className="flex items-center gap-3 text-sm text-white/70">
|
||||
👍 {f.up} 👎 {f.down}
|
||||
<button className={btn} onClick={() => setEditing(f)}>Edit</button>
|
||||
<button className={btn} onClick={() => remove(f)}>Delete</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Team({ me }: { me: string }) {
|
||||
const [rows, setRows] = useState<Member[]>([])
|
||||
const [f, setF] = useState({ name: '', email: '', password: '' })
|
||||
const [error, setError] = useState('')
|
||||
const load = useCallback(() => api<Member[]>('/staff/team').then(setRows), [])
|
||||
useEffect(() => { load() }, [load])
|
||||
const add = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try { await api('/staff/team', 'POST', f); setF({ name: '', email: '', password: '' }); setError(''); load() } catch (err) { setError((err as Error).message) }
|
||||
}
|
||||
const remove = async (m: Member) => { if (confirm(`Remove ${m.name}?`)) { try { await api(`/staff/team/${m.id}`, 'DELETE'); load() } catch (err) { setError((err as Error).message) } } }
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ul className="space-y-2">
|
||||
{rows.map((m) => (
|
||||
<li key={m.id} className={`${box} flex items-center justify-between !py-4`}>
|
||||
<span>{m.name} <span className="text-white/60 text-sm">· {m.email}</span></span>
|
||||
{m.email !== me && <button className={btn} onClick={() => remove(m)}>Remove</button>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<form onSubmit={add} className={`${box} space-y-3`}>
|
||||
<h2 className="text-xl">Add a team member</h2>
|
||||
<input className={input} placeholder="Name" aria-label="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} />
|
||||
<input className={input} type="email" placeholder="Email" aria-label="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />
|
||||
<input className={input} type="password" placeholder="Temporary password (10+ characters)" aria-label="Password" value={f.password} onChange={(e) => setF({ ...f, password: e.target.value })} autoComplete="new-password" />
|
||||
{error && <p role="alert" className="text-red-300 text-sm">{error}</p>}
|
||||
<button type="submit" className="bg-white text-black rounded-full px-6 py-2 text-sm font-medium">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Staff() {
|
||||
const [me, setMe] = useState<{ name: string; email: string } | null | undefined>(undefined)
|
||||
const [emailOk, setEmailOk] = useState(true)
|
||||
const [tab, setTab] = useState<Tab>('tickets')
|
||||
const check = useCallback(() => {
|
||||
api<{ name: string; email: string }>('/me').then((m) => { setMe(m); api<{ emailConfigured: boolean }>('/staff/status').then((s) => setEmailOk(s.emailConfigured)) }).catch(() => setMe(null))
|
||||
}, [])
|
||||
useEffect(() => { check() }, [check])
|
||||
const logout = async () => { await api('/logout', 'POST'); setMe(null) }
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white px-6 py-10">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<a href="#" className="text-white/70 hover:text-white text-sm">← Back to site</a>
|
||||
{me && <button onClick={logout} className={`${btn} inline-flex items-center gap-2`}><LogOut size={14} /> Log out ({me.name})</button>}
|
||||
</div>
|
||||
{me === undefined ? <p className="text-white/60">Loading...</p> : me === null ? <Login onDone={check} /> : (
|
||||
<>
|
||||
{!emailOk && <p className="mb-4 text-amber-300 text-sm">Email is not set up on the server yet, so new tickets are saved here but not emailed.</p>}
|
||||
<div className="flex gap-2 mb-6">
|
||||
{(['tickets', 'faqs', 'team'] as Tab[]).map((t) => (
|
||||
<button key={t} onClick={() => setTab(t)} aria-pressed={tab === t} className={`${btn} capitalize ${tab === t ? 'bg-white/20' : ''}`}>{t === 'faqs' ? 'FAQs' : t}</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'tickets' && <Tickets />}{tab === 'faqs' && <Faqs />}{tab === 'team' && <Team me={me.email} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
const VIDEO_URL =
|
||||
'https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260328_115001_bcdaa3b4-03de-47e7-ad63-ae3e392c32d4.mp4'
|
||||
const FADE_MS = 500
|
||||
const FADE_OUT_REMAINING = 0.55
|
||||
|
||||
export default function VideoBackground() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const fadingOutRef = useRef(false)
|
||||
|
||||
const fadeTo = useCallback((target: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
|
||||
const startOpacity = parseFloat(video.style.opacity || '0')
|
||||
const startTime = performance.now()
|
||||
const step = (now: number) => {
|
||||
const t = Math.min((now - startTime) / FADE_MS, 1)
|
||||
video.style.opacity = String(startOpacity + (target - startOpacity) * t)
|
||||
rafRef.current = t < 1 ? requestAnimationFrame(step) : null
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
|
||||
}, [])
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
const video = videoRef.current
|
||||
if (!video || !video.duration || fadingOutRef.current) return
|
||||
if (video.duration - video.currentTime <= FADE_OUT_REMAINING) {
|
||||
fadingOutRef.current = true
|
||||
fadeTo(0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
video.style.opacity = '0'
|
||||
window.setTimeout(() => {
|
||||
video.currentTime = 0
|
||||
void video.play().catch(() => {})
|
||||
fadingOutRef.current = false
|
||||
fadeTo(1)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={VIDEO_URL}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
style={{ opacity: 0 }}
|
||||
className="absolute inset-0 w-full h-full object-cover translate-y-[17%]"
|
||||
onLoadedData={() => fadeTo(1)}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onEnded={handleEnded}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"category": "Access & logins",
|
||||
"question": "I forgot my password",
|
||||
"answer": "Most organisations have a self-service reset link on the sign-in page, usually 'Forgot password' or 'Can't access your account'. You'll be asked to verify your identity, normally with a code sent to a backup email or phone number you registered earlier. If there's no self-service option, or verification fails, your IT service desk can reset it after confirming your identity another way. Keep any backup codes you were given when MFA was set up somewhere safe, they're often the fastest way back in.",
|
||||
"link": "https://support.microsoft.com/en-us/account-billing/how-to-reset-your-microsoft-account-password-eff4f067-5042-c1a3-fe72-b04d60556c37",
|
||||
"linkLabel": "Microsoft: reset your password"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"category": "Access & logins",
|
||||
"question": "I'm locked out after too many failed attempts",
|
||||
"answer": "This is a security feature, not a fault, it stops someone guessing your password repeatedly. Most systems clear the lockout automatically after a set time, often 15 to 30 minutes, so waiting is sometimes the fastest fix. If it's urgent, or the lockout doesn't clear itself, your IT service desk can unlock the account manually once they've verified it's really you.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"category": "Access & logins",
|
||||
"question": "My multi-factor login (authenticator app) isn't working",
|
||||
"answer": "If you've lost your phone or the authenticator app stopped generating valid codes, try a saved backup code first, most systems issue a set of one-time codes when MFA is first set up. If you don't have one, contact IT to re-register your MFA device, this normally requires an identity check (staff or student ID, or a manager confirming it's you) since it's effectively resetting your account security.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"category": "Access & logins",
|
||||
"question": "My account is disabled or expired after returning from leave",
|
||||
"answer": "Accounts are sometimes automatically disabled after a period of inactivity, or a leave period that wasn't flagged as 'returning' in the system. This is an account status issue in the directory (Active Directory or similar), not a password problem, ask IT to check whether the account itself is disabled or expired rather than resetting the password again.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"category": "Access & logins",
|
||||
"question": "Single sign-on (SSO) keeps redirecting in a loop",
|
||||
"answer": "An SSO loop, bouncing between your organisation's login page and the app without ever landing you in, is usually a cookie or session problem rather than your password. Try a private or incognito browser window first, if that works cleanly, clear cookies for your organisation's login domain. If it still loops, the connection between your identity provider and that specific app may need IT to check its configuration.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"category": "Access & logins",
|
||||
"question": "A guest or contractor account isn't working",
|
||||
"answer": "Guest and contractor accounts usually go through a separate approval process, often needing an internal sponsor, and typically expire automatically after a set period for security. If a guest account stops working, check with the person who sponsored the access first, then IT if they confirm it should still be active.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"category": "Access & logins",
|
||||
"question": "My security questions aren't being accepted during recovery",
|
||||
"answer": "Recovery answers need to match exactly what was originally entered, including capitalisation in some systems, so a common cause is simply misremembering the exact wording. If you've genuinely forgotten the answers, look for a 'try another way' option instead of repeatedly guessing, most recovery tools offer an alternative path such as an email or SMS code.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"category": "Access & logins",
|
||||
"question": "A new password keeps being rejected as too weak or previously used",
|
||||
"answer": "Organisations enforce password rules (minimum length, a mix of character types, and often a block on reusing recent passwords) to reduce the risk of a compromised account. A longer passphrase, three or four random unrelated words plus a number and symbol, is both stronger and easier to remember than a short complex one, and tends to pass these checks more easily. Avoid names, birthdays or the organisation's own name.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"category": "Network & wifi",
|
||||
"question": "My wifi keeps dropping",
|
||||
"answer": "Try moving closer to the access point, restarting your device's wifi, and checking if other devices nearby have the same problem, that tells you whether it's your device or the network. On a corporate or school network with certificate-based login (802.1x), an expired certificate is a common cause, IT can reissue it. A network adapter driver that's out of date after a Windows update is another very common cause worth ruling out.",
|
||||
"link": "https://support.microsoft.com/en-us/windows/fix-wi-fi-connection-issues-in-windows-9424a1f7-6a3b-65a6-4d78-7f07eee84d2c",
|
||||
"linkLabel": "Microsoft: fix Wi-Fi connection issues"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"category": "Network & wifi",
|
||||
"question": "The VPN won't connect",
|
||||
"answer": "Check your normal internet connection works first. Then fully close and reopen the VPN app rather than just reconnecting, a half-open session is a common cause. Public wifi (cafes, hotels) sometimes blocks VPN traffic entirely until you've accepted its own login page in a browser first.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"category": "Network & wifi",
|
||||
"question": "I can't reach a shared drive or folder",
|
||||
"answer": "Confirm you're connected to the VPN or office network, shared drives usually don't work from the open internet. If it still fails, the folder permissions may have changed, ask IT to check your access rather than repeatedly trying to reconnect.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"category": "Network & wifi",
|
||||
"question": "I'm getting an IP conflict or 'limited connectivity' message",
|
||||
"answer": "This usually means your device didn't get a valid address from the network's DHCP server, often after being moved between networks or waking from sleep. Disconnecting and reconnecting to wifi, or running `ipconfig /release` then `ipconfig /renew` on Windows, fixes most cases. If it keeps happening on the same device, report it, it can indicate a DHCP address pool running low.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"category": "Network & wifi",
|
||||
"question": "A captive portal (hotel or cafe wifi login page) won't load",
|
||||
"answer": "If the login page doesn't appear automatically, try opening a plain, non-secure website, some networks only trigger the portal on unencrypted traffic. A corporate VPN client often blocks the portal from appearing at all, disconnect the VPN first, log in to the wifi, then reconnect the VPN.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"category": "Network & wifi",
|
||||
"question": "Wifi shows connected but the internet is very slow",
|
||||
"answer": "Being 'connected' only confirms a local network link, not that everything beyond it is fast. Run a speed test, then check whether it's isolated to one device (a device or driver issue) or affects everyone nearby, which usually points to the access point, the internet link itself, or a bandwidth-heavy device worth reporting to IT.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"category": "Network & wifi",
|
||||
"question": "A mapped network drive keeps failing after a password change",
|
||||
"answer": "A mapped drive often stores the old credentials separately, so it can keep failing silently even though you can log in to everything else fine. Disconnect the mapped drive and reconnect it fresh, entering the new password when prompted, rather than relying on the saved ones.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"category": "Network & wifi",
|
||||
"question": "A Bluetooth device won't pair",
|
||||
"answer": "Turn Bluetooth off and on again on both devices, and make sure the device is actually in pairing mode. If it was previously paired with a different computer, 'forget' or remove the old pairing on both ends first, most Bluetooth accessories only hold a limited number of paired connections at once.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"category": "Hardware",
|
||||
"question": "The printer won't print",
|
||||
"answer": "Check it's powered on, has paper and toner, and that you picked the right printer name in the print dialog. Network printers can show as 'offline' if they've lost wifi, turning them off and on again often fixes this.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"category": "Hardware",
|
||||
"question": "An external monitor isn't detected",
|
||||
"answer": "Check the cable is firmly in at both ends and try a different port or cable if you have one. Then open your display settings and click 'Detect'. A driver or graphics update can also help on older laptops.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"category": "Hardware",
|
||||
"question": "A USB device isn't recognised",
|
||||
"answer": "Try a different USB port, and a different cable if it's detachable. If it still doesn't show up, check Device Manager (Windows) or System Report (Mac) for a warning icon next to the device.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"category": "Hardware",
|
||||
"question": "A laptop won't turn on at all",
|
||||
"answer": "First rule out a flat battery, plug in the charger, check for a charging light, then leave it 15 to 30 minutes before trying again, some laptops won't start with a completely dead battery even while charging. If there's a charging light but still nothing on screen, try an external monitor, that tells you whether it's a screen problem or the laptop genuinely isn't starting.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"category": "Hardware",
|
||||
"question": "The battery is draining fast or won't charge",
|
||||
"answer": "Test the charger and cable on a different device or outlet first, to rule out a faulty charger rather than the laptop itself. Battery health does genuinely fade over 2 to 4 years of daily use, if the laptop is older and the drop was gradual, a battery replacement through IT asset management is often the real fix rather than a software setting.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"category": "Hardware",
|
||||
"question": "The keyboard or trackpad isn't responding",
|
||||
"answer": "Restart the device first, a stuck driver is a common and simple cause. If only specific keys are affected, check for anything physically stuck underneath, common after spills or crumbs, and try an external USB keyboard or mouse to confirm whether it's the built-in hardware or a wider fault.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"category": "Hardware",
|
||||
"question": "A docking station isn't detecting monitors or peripherals",
|
||||
"answer": "Confirm the dock itself has power, many need their own power cable, not just the laptop cable, and try unplugging the laptop from the dock and reconnecting it firmly. Docks that connect via USB-C or Thunderbolt can be picky about cable quality, swap the cable before assuming the dock has failed.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"category": "Hardware",
|
||||
"question": "The webcam isn't detected by any app",
|
||||
"answer": "Some laptops have a physical privacy shutter or a function-key toggle that disables the camera entirely, check for both before assuming it's broken. Checking Device Manager (Windows) or System Information (Mac) shows whether the operating system sees the camera at all, if it's not listed there, it's a driver or hardware fault rather than an app setting.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"category": "Software & apps",
|
||||
"question": "My computer is running very slow",
|
||||
"answer": "Restart it first, this clears a surprising number of slowdowns. After that, check how much storage is free and close programs you're not using that start automatically. If it's still slow, let IT know, it could be a background update or a bigger issue.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"category": "Software & apps",
|
||||
"question": "An app keeps crashing or won't open",
|
||||
"answer": "Close and reopen it, then restart your computer if that doesn't help. Check whether an update is available. If it's an approved work app that IT installed for you, they may need to reinstall it rather than you doing it yourself.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"category": "Software & apps",
|
||||
"question": "My email isn't syncing or I'm missing messages",
|
||||
"answer": "Check your internet connection and your junk or spam folder. Also check your mailbox isn't full, a full mailbox silently stops receiving mail in some systems. Outlook and Gmail both have a 'repair account' option worth trying.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"category": "Software & apps",
|
||||
"question": "My camera isn't working in Microsoft Teams",
|
||||
"answer": "Close other apps that might already be using the camera (like Zoom or FaceTime), unplug and reconnect it if it's external, and restart the device. Make a test call from Teams' device settings to check camera, mic and speaker together in one go.",
|
||||
"link": "https://support.microsoft.com/en-US/teams/meetings/my-camera-isn-t-working-in-microsoft-teams",
|
||||
"linkLabel": "Microsoft: Teams camera troubleshooting"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"category": "Software & apps",
|
||||
"question": "Zoom audio or video isn't working",
|
||||
"answer": "Check the correct microphone, speaker and camera are selected inside Zoom's own settings, not just that your device isn't muted at the operating system level. If it's a browser-based meeting, the browser also needs camera and microphone permission granted specifically for that site.",
|
||||
"link": "https://support.zoom.us/",
|
||||
"linkLabel": "Zoom support"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"category": "Software & apps",
|
||||
"question": "Windows Update is stuck or won't install",
|
||||
"answer": "Restart the device once and let it sit for at least 30 minutes, some updates genuinely take a long time on the 'working on updates' screen, and interrupting them can cause more problems than waiting. If it's still stuck well beyond that, or fails repeatedly, note the exact error code for IT, the built-in update troubleshooter resolves a lot of common stuck updates on its own.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"category": "Software & apps",
|
||||
"question": "Office or Microsoft 365 shows 'unlicensed product' or won't activate",
|
||||
"answer": "This usually means the app can't confirm your licence with Microsoft's servers, often because you're signed in with the wrong account, or you've been offline too long since the last successful check-in. Sign out and back in with your work or school account specifically, not a personal Microsoft account, and make sure you're online or on VPN when it tries to reactivate.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"category": "Software & apps",
|
||||
"question": "'Access denied' or 'permission denied' opening a file",
|
||||
"answer": "This is almost always a permissions setting on the file or folder itself, not a fault with your computer. Confirm you're opening it from the right location, a synced local copy can behave differently to the shared original, and if you believe you should have access, ask the file's owner or IT to check the permission list rather than trying to force it open.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"category": "Security",
|
||||
"question": "I received a suspicious email, is it phishing?",
|
||||
"answer": "Don't click links or open attachments. Check the sender's actual email address, not just the display name, and look for urgency or requests for passwords or gift cards, common phishing signs. Report it to your IT security team rather than deleting it, they may need to warn others across the organisation.",
|
||||
"link": "https://www.cyber.gov.au/report-and-recover",
|
||||
"linkLabel": "ACSC: report a cybercrime (ReportCyber)"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"category": "Security",
|
||||
"question": "My computer crashed or showed a blue screen",
|
||||
"answer": "Note down any error code or message shown before it restarts, a photo on your phone is fine. Restart the computer. If it happens more than once, that error code is exactly what IT will need to diagnose it.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"category": "Security",
|
||||
"question": "I think I've been hit by ransomware",
|
||||
"answer": "If files suddenly become inaccessible with strange new extensions, or a ransom note appears, disconnect the device from the network immediately (unplug the cable or turn off wifi) without shutting it down, this can stop it spreading before you power off. Don't pay anything and don't try to remove it yourself, contact IT security immediately, this is treated as a priority incident.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"category": "Security",
|
||||
"question": "I suspect my account has been compromised",
|
||||
"answer": "Change your password immediately from a device you trust, and check for any MFA devices, mail forwarding rules, or app connections you don't recognise, attackers often quietly add a forwarding rule or a second MFA device to keep access even after the password changes. Report it to IT security even after you've changed the password, they may need to check for wider impact.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"category": "Security",
|
||||
"question": "What actually makes a strong password or passphrase",
|
||||
"answer": "Length matters more than complexity, three or four random unrelated words make a passphrase that's both stronger and easier to remember than a short complex one. Never reuse a password across personal and work accounts, and use a password manager rather than trying to remember unique passwords for everything, most organisations provide or approve one.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"category": "Security",
|
||||
"question": "How to report a data breach or accidental data exposure",
|
||||
"answer": "If personal or sensitive information was sent to the wrong person, uploaded somewhere public by mistake, or a device holding such data is lost or stolen, report it to IT and your privacy contact as soon as possible. Timing genuinely matters for breach response, and in government and other regulated sectors there can be mandatory notification obligations attached.",
|
||||
"link": "https://www.cyber.gov.au/report-and-recover",
|
||||
"linkLabel": "ACSC: report and recover"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"category": "Security",
|
||||
"question": "Windows is asking for a BitLocker recovery key",
|
||||
"answer": "This usually appears after a hardware or firmware change, like a BIOS update, a new battery, or a changed boot order, that the encryption chip treats as suspicious, it's the protection working as intended, not a sign of a hack. The 48-digit recovery key is normally stored in your organisation's directory (Azure AD/Intune) or was saved separately when the device was set up, IT can retrieve it if you don't have it yourself. Microsoft itself cannot recover a lost key, so if neither you nor IT can locate it, the drive has to be reset.",
|
||||
"link": "https://support.microsoft.com/en-us/help/4026181",
|
||||
"linkLabel": "Microsoft: find your BitLocker recovery key"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"category": "Security",
|
||||
"question": "A Mac is asking for a FileVault recovery key",
|
||||
"answer": "Similar to Windows BitLocker, this appears after certain hardware or firmware changes and is the encryption protecting your data working as designed. The recovery key is usually escrowed with your organisation's MDM (Jamf or similar), IT can retrieve it if it wasn't saved when the device was set up.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"category": "Security",
|
||||
"question": "Antivirus or endpoint protection just quarantined a file or blocked something",
|
||||
"answer": "Don't try to restore or override it yourself, even if you're confident the file is safe, false positives do happen but confirming that is IT security's job, not yours. Report exactly what you were doing and what the alert said, that detail is what determines whether it's a false positive or something needing wider action.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"category": "Security",
|
||||
"question": "A USB drive or device is blocked by policy",
|
||||
"answer": "Many organisations block USB storage devices entirely as a data-loss-prevention measure, this is deliberate policy rather than a fault, and it usually can't be worked around with a different port or drive. If you have a genuine business need to transfer files this way, request an exception through IT rather than trying another device.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"category": "Sector notes",
|
||||
"question": "Why are some settings locked on my device?",
|
||||
"answer": "Government, school and many corporate devices are deliberately locked down for security, this is policy, not a fault. Australian government agencies generally follow the ACSC's Essential Eight security controls, which is why things like installing your own software or changing certain settings are restricted to IT staff.",
|
||||
"link": "https://www.cyber.gov.au/resources-business-and-government/essential-cybersecurity/essential-eight",
|
||||
"linkLabel": "ACSC: Essential Eight explained"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"category": "Sector notes",
|
||||
"question": "School: why is a legitimate website or resource blocked?",
|
||||
"answer": "Australian schools typically apply internet content filtering as required by state and territory education department policy, and manage student devices through a mobile device management (MDM) system. If a legitimate educational site is blocked, that's a filtering rule, not a fault, report it to the school's IT support so they can review or whitelist it rather than trying to work around the filter.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"category": "Sector notes",
|
||||
"question": "Government: why can't I install software myself?",
|
||||
"answer": "Restricting who can install software (application control) is one of the Essential Eight strategies Australian government agencies are expected to implement, it stops unapproved or malicious software running on government devices. If you need software for a work purpose, the usual path is a request to IT for review and installation through an approved, managed process.",
|
||||
"link": "https://www.cyber.gov.au/resources-business-and-government/essential-cybersecurity/essential-eight",
|
||||
"linkLabel": "ACSC: Essential Eight explained"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"category": "Sector notes",
|
||||
"question": "Corporate: why can't I do everything on my personal (BYOD) device?",
|
||||
"answer": "A personal device enrolled for work access (BYOD) is usually only given a limited, contained work profile rather than full management of the whole device, protecting both your personal data and the organisation's. If a work app or file won't open, or copy-paste out of a work app is blocked, that's often a deliberate BYOD policy restriction rather than a technical fault.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"category": "How IT support works",
|
||||
"question": "What do Level 1, 2, 3 (and 4) support actually mean?",
|
||||
"answer": "Level 1 is the frontline help desk, handling common self-service issues like this page's Access, Wifi and Hardware topics, usually resolved in one call or ticket. Level 2 is more technical staff who handle things needing deeper diagnosis, like the Network Hardware topics here. Level 3 is specialist or senior engineers, often involved in design and complex incidents, and Level 4 is the vendor's own support (Cisco TAC, Fortinet support), brought in when the issue is inside the product itself, not the configuration.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"category": "How IT support works",
|
||||
"question": "When should something be escalated to the next level?",
|
||||
"answer": "As a general rule, escalate when the fix needs a permission, tool, or specialist knowledge the current level doesn't have, not simply because an issue is taking a long time. A good escalation includes what's already been tried, exact error messages, and which users or systems are affected, that context is what actually speeds up the next level's diagnosis.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"category": "How IT support works",
|
||||
"question": "What's the difference between an incident, a request, and a problem?",
|
||||
"answer": "An incident is something broken right now (the printer isn't printing, the VPN is down), the goal is to restore service quickly. A request is asking for something new (new software, more storage, account access), it follows an approval process rather than a fix. A problem is the underlying root cause behind repeated incidents, if the same printer fails every Monday, that pattern gets investigated as a problem so it stops recurring, rather than just fixed each time it happens.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"category": "How IT support works",
|
||||
"question": "Where does a change request fit in?",
|
||||
"answer": "A change request is a planned, deliberate modification to a system, like a firmware upgrade or a new firewall rule, done through an approval process because unplanned changes are one of the most common causes of new incidents. This is why network hardware changes especially are usually scheduled for a maintenance window rather than made on the spot.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"category": "Mobile & remote work",
|
||||
"question": "A phone or tablet won't enrol in the company's mobile device management (MDM)",
|
||||
"answer": "Confirm you're using the exact enrolment method IT specified (a QR code, an enrolment URL, or a specific app store link), a generic 'add work account' option in phone settings sometimes skips steps the proper enrolment method includes. Also check the device's operating system version meets what IT requires, MDM enrolment is commonly blocked on outdated OS versions for security reasons.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"category": "Mobile & remote work",
|
||||
"question": "Work email or apps stopped working on a personal phone after an update",
|
||||
"answer": "A phone OS update can reset app permissions or trigger a compliance re-check by the organisation's MDM, temporarily blocking access until the device is confirmed to still meet security requirements like having a passcode set. Check for a compliance or 'action needed' notification from the company portal app before assuming it's broken.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"category": "Mobile & remote work",
|
||||
"question": "A mobile VPN drops every time the screen locks",
|
||||
"answer": "Many phones aggressively kill background network connections to save battery, including VPN apps, unless the app is specifically excluded from battery optimisation. Check the phone's battery settings for an 'unrestricted' or 'no optimisation' option for that VPN app specifically.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"category": "Mobile & remote work",
|
||||
"question": "A remote desktop (RDP) session freezes or won't connect",
|
||||
"answer": "Confirm you're connected to the VPN first if the remote machine is inside a private network, RDP almost never works over the open internet directly, for good security reasons. If the VPN is connected but it still fails, check whether the remote machine is actually powered on and not asleep, a surprisingly common cause.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"category": "Mobile & remote work",
|
||||
"question": "A VDI session (Citrix, Horizon) is slow or won't launch",
|
||||
"answer": "Test your own internet connection's latency and stability first, VDI performance is very sensitive to a poor home connection in a way a locally installed app isn't. If your connection is fine, try a different VDI client version or a browser-based login as a workaround while reporting the failure to IT with the exact error shown.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"category": "Mobile & remote work",
|
||||
"question": "Working from home, the VPN connects but internal apps still time out",
|
||||
"answer": "Some VPN setups use 'split tunnelling', where only traffic for specific internal systems goes through the VPN and everything else uses your home internet directly, if a newer internal app isn't yet included in that list, it will time out even with the VPN connected. That's a configuration detail for IT to add, not something you can usually fix from your end.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"category": "Cloud storage & sync",
|
||||
"question": "OneDrive is stuck on 'processing changes' or won't sync",
|
||||
"answer": "Pause and resume syncing from the OneDrive icon in the system tray, this resets a stuck sync engine more often than you'd expect. A very long file path, a file open in another program, or special characters in a file name are the next most common causes of a stuck sync. Moving the affected file out of the synced folder and back in again can also clear a single stuck item without resetting the whole sync.",
|
||||
"link": "https://support.microsoft.com/en-us/onedrive/fix-onedrive-sync-problems",
|
||||
"linkLabel": "Microsoft: fix OneDrive sync problems"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"category": "Cloud storage & sync",
|
||||
"question": "Google Drive shows a red exclamation mark on a file",
|
||||
"answer": "This means that specific file failed to sync, usually because it's open in another application, too large for your remaining storage quota, or has a file type or name Drive can't handle. Hover over the icon for the exact reason, then close the file elsewhere or check your storage quota before retrying.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"category": "Cloud storage & sync",
|
||||
"question": "Cloud storage quota is full",
|
||||
"answer": "Check what's actually taking up space first, large video files and old email attachments are common causes, rather than immediately requesting more storage. If you genuinely need more for legitimate work purposes, that's a request rather than an incident (see 'How IT support works'), so it goes through IT's normal approval process.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"category": "Cloud storage & sync",
|
||||
"question": "A shared file link says 'access denied' for someone outside the organisation",
|
||||
"answer": "Many organisations restrict external sharing by default for security, so a link that works fine internally can fail for an outside recipient. Check the specific sharing settings on that file or folder, external sharing is often something that needs to be deliberately enabled per-file or per-site, rather than being on by default.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"category": "Cloud storage & sync",
|
||||
"question": "Files show as 'online-only' and won't open without internet",
|
||||
"answer": "Cloud sync clients often keep infrequently used files as a placeholder to save local disk space, downloading the real content only when opened, which fails without an internet connection. Right-click the specific file or folder you need offline and choose 'always keep on this device' before you lose connectivity, before a flight or a site visit, for example.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"category": "macOS specific",
|
||||
"question": "A Mac update is stuck or won't install",
|
||||
"answer": "Confirm there's enough free disk space, macOS updates need significant free space to stage the install and fail silently without it. Restart once and try again from System Settings rather than clicking install repeatedly, which can leave the updater in a confused state.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"category": "macOS specific",
|
||||
"question": "Time Machine backup keeps failing",
|
||||
"answer": "Check the backup drive isn't full and is actually connected and mounted, an intermittent external drive connection is the most common cause. If backing up to a network location, confirm you can still reach that server or NAS at all, a backup failure is often really a network or permissions issue in disguise.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"category": "macOS specific",
|
||||
"question": "A Mac keeps asking for the keychain password unexpectedly",
|
||||
"answer": "This usually happens after a password change that the login keychain doesn't yet know about, since it's a separate password store from your account login. Go to Keychain Access and update or reset the login keychain's password to match your new account password, rather than repeatedly dismissing the prompt. Resetting the keychain does clear its saved passwords, so only do this if you can't recover the old one.",
|
||||
"link": "https://support.apple.com/guide/keychain-access/kyca2429/mac",
|
||||
"linkLabel": "Apple: update your keychain password"
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"category": "macOS specific",
|
||||
"question": "A Mac won't join a corporate wifi network with certificate login",
|
||||
"answer": "Confirm the required certificate is actually installed in the Mac's keychain, it's normally pushed automatically by MDM (Jamf or similar) but can fail to install if the Mac hasn't checked in with MDM recently. Manually forcing an MDM check-in (System Settings > general profile management) before retrying the wifi connection resolves this in many cases.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A Cisco switch port shows no link light",
|
||||
"answer": "Start at the physical layer: reseat the cable, try a different port, and check for a bad SFP. Then confirm the port isn't administratively shut down with `show interfaces status`, if it is, `no shutdown` brings it back up.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A Cisco port went err-disabled",
|
||||
"answer": "This means the switch shut the port down itself, usually after a security violation, a BPDU guard trip, or a duplex mismatch. Check the reason with `show interfaces status err-disabled`, fix the underlying cause, then cycle the port with `shutdown` then `no shutdown`.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "Devices can't reach each other across a Cisco VLAN or trunk",
|
||||
"answer": "Confirm the VLAN exists on both switches with `show vlan brief`, then check the trunk with `show interfaces trunk`. A mismatched native VLAN or a VLAN missing from the allowed list is the most common cause.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 69,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A Cisco router or Layer 3 switch can't reach a remote subnet",
|
||||
"answer": "Check `show ip interface brief` to confirm the interface is up, then `show ip route` to confirm a route exists. `ping` and `traceroute` will show exactly where the path breaks.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 70,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A Cisco device is running hot (high CPU)",
|
||||
"answer": "Check `show processes cpu sorted` to see what's using it. A spanning-tree loop or broadcast storm is a common cause on switches, `show spanning-tree` will show repeated topology changes if that's happening.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 71,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A FortiGate isn't passing traffic that looks like it should be allowed",
|
||||
"answer": "Confirm a firewall policy actually matches the source, destination and service, then use `diagnose sniffer packet any 'host x.x.x.x' 4` to see if traffic is arriving, and `diagnose debug flow` to see how the FortiGate is treating it.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 72,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A FortiGate site-to-site IPsec VPN won't come up",
|
||||
"answer": "Phase 1 and Phase 2 settings (encryption, PSK or certificate, proposals) must match exactly on both ends. `diagnose vpn ike log-filter` with `diagnose debug application ike -1` shows exactly where negotiation is failing.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 73,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A FortiGate is slow or unresponsive",
|
||||
"answer": "Check `get system performance status` for CPU, memory and session count, and `diagnose sys top` to see which process is responsible. A nearly-full session table is a common cause on busy units.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 74,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "Can't reach the FortiGate admin GUI from a particular network",
|
||||
"answer": "FortiGate can restrict admin access to trusted hosts and specific interfaces. Check the admin account's trusted host settings and confirm the interface you're connecting through has HTTPS/SSH admin access enabled.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 75,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "How to safely back up a Cisco device's configuration",
|
||||
"answer": "Before any change, save the running config to the startup config with `copy running-config startup-config`, then copy it off the device via TFTP or SCP so there's an external copy too. Always confirm the backup completed before making changes.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 76,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "How to safely upgrade Cisco IOS firmware",
|
||||
"answer": "Confirm the new image is supported on the exact hardware model and that there's enough flash and RAM. Copy the image to flash, verify its checksum, set the new boot variable, save the config, then reload in a maintenance window in case it needs rolling back.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 77,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "Lost the password to a Cisco device",
|
||||
"answer": "Password recovery needs physical console access and a reboot into ROMMON mode to bypass the startup config temporarily. This is a standard, well-documented process, but the exact key combination differs by model, check the vendor documentation for your specific device first.",
|
||||
"link": "https://www.cisco.com/c/en/us/support/index.html",
|
||||
"linkLabel": "Cisco Support"
|
||||
},
|
||||
{
|
||||
"id": 78,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A wireless access point won't join the Cisco WLC",
|
||||
"answer": "Check the AP can discover the controller (DHCP option 43, DNS, or same-subnet broadcast), and that the AP's firmware is compatible with the controller's software version, a mismatch is a common cause of a failed join.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 79,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "Traffic is blocked on a Cisco ASA despite a matching ACL",
|
||||
"answer": "Check NAT rules aren't exempting or redirecting the traffic unexpectedly, and confirm the ACL is applied in the right direction on the right interface. `packet-tracer` simulates a specific flow and shows exactly which rule drops it.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 80,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "One FortiGate in an HA cluster won't sync",
|
||||
"answer": "Check both units are running the identical firmware version first, a mismatch is the most common HA sync blocker. `diagnose sys ha status` shows the current sync state and any errors.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 81,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A FortiSwitch isn't showing up under FortiGate management",
|
||||
"answer": "FortiGate manages FortiSwitches over FortiLink, confirm the FortiLink interface is correctly configured and that the switch port used for FortiLink hasn't been repurposed for something else.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 82,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A FortiAP won't join its FortiGate",
|
||||
"answer": "Confirm the AP can discover the FortiGate (same subnet, or DHCP option 138/CAPWAP discovery across routed networks) and that the AP and FortiGate firmware are a supported combination.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 83,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "SD-WAN traffic is taking the wrong path on a FortiGate",
|
||||
"answer": "Check the SD-WAN rule order, first match wins, and whether the SLA health checks for that link are actually passing, a failed health check silently removes a path from consideration. `diagnose sys sdwan` shows live link status.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 84,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "A FortiGate says its licence or support contract has expired",
|
||||
"answer": "An expired contract stops signature and firmware updates but doesn't usually stop the firewall passing traffic. Check the exact status with `get system status`, then renew through your reseller or Fortinet, running without updates for long is a real security risk.",
|
||||
"link": "",
|
||||
"linkLabel": ""
|
||||
},
|
||||
{
|
||||
"id": 85,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "Where to find full Cisco documentation and command syntax",
|
||||
"answer": "Always confirm exact command syntax against Cisco's own docs, it changes between switch/router models and IOS versions.",
|
||||
"link": "https://www.cisco.com/c/en/us/support/index.html",
|
||||
"linkLabel": "Cisco Support"
|
||||
},
|
||||
{
|
||||
"id": 86,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "Where to find full Fortinet documentation and command syntax",
|
||||
"answer": "FortiOS CLI commands and defaults vary by firmware version, the official Fortinet Document Library is the source of truth.",
|
||||
"link": "https://docs.fortinet.com/",
|
||||
"linkLabel": "Fortinet Document Library"
|
||||
},
|
||||
{
|
||||
"id": 87,
|
||||
"category": "Network hardware (IT staff)",
|
||||
"question": "An IPsec tunnel looks correctly configured but still won't come up",
|
||||
"answer": "When the config looks right on paper, checking the live IKE negotiation logs tells you more than re-checking the configuration a third time. Fortinet's own support community has a detailed walkthrough of the exact `diagnose` commands and log filters their engineers use for exactly this scenario.",
|
||||
"link": "https://community.fortinet.com/t5/FortiGate/Troubleshooting-Tip-IPsec-VPN-tunnels/ta-p/195955",
|
||||
"linkLabel": "Fortinet Community: IPsec VPN troubleshooting"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.liquid-glass {
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
background-blend-mode: luminosity;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
border: none;
|
||||
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.liquid-glass::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1.4px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.45) 0%,
|
||||
rgba(255, 255, 255, 0.15) 20%,
|
||||
rgba(255, 255, 255, 0) 40%,
|
||||
rgba(255, 255, 255, 0) 60%,
|
||||
rgba(255, 255, 255, 0.15) 80%,
|
||||
rgba(255, 255, 255, 0.45) 100%
|
||||
);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
// Safe wrappers: localStorage can be blocked (private mode), so never let it crash the page.
|
||||
export function load<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? (JSON.parse(raw) as T) : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function save(key: string, value: unknown): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface Faq {
|
||||
id: number
|
||||
category: string
|
||||
question: string
|
||||
answer: string
|
||||
link: string
|
||||
linkLabel: string
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/ContactForm.tsx","./src/Faq.tsx","./src/Staff.tsx","./src/VideoBackground.tsx","./src/main.tsx","./src/storage.ts","./src/types.ts","./src/vite-env.d.ts","./vite.config.ts"],"version":"5.6.3"}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
server: { proxy: { '/api': 'http://localhost:3001' } },
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "cycle-atlas-root",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Root wrapper so hosts can build and start the IT Support site in it-support/",
|
||||
"engines": { "node": ">=22.13" },
|
||||
"scripts": {
|
||||
"build": "npm --prefix it-support install --include=dev && npm --prefix it-support run build",
|
||||
"start": "NODE_ENV=production node it-support/server/index.js"
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"ad-creative-video": {
|
||||
"source": "iart-ai/ad-video-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/ad-creative-video/SKILL.md",
|
||||
"computedHash": "94c33e3435abd802773c4b7e6058f3d47456bc56354d87b1e6fc2b689757735d"
|
||||
},
|
||||
"launch-video": {
|
||||
"source": "iart-ai/ad-video-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/launch-video/SKILL.md",
|
||||
"computedHash": "a55b82d2ca76de5bb80a1217594b11d1bf1ae4485b487bcad7ab256e2f116790"
|
||||
},
|
||||
"testimonial-video": {
|
||||
"source": "iart-ai/ad-video-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/testimonial-video/SKILL.md",
|
||||
"computedHash": "0d71fa501ffc13fc979d4d93865785c16741c6b4041ccbb3926eaf0da0f486da"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user