diff --git a/.gitignore b/.gitignore index a333bff..e2c6a9f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies /node_modules +.pnpm-store /.pnp .pnp.js diff --git a/app/components/ambient-grain.tsx b/app/components/ambient-grain.tsx new file mode 100644 index 0000000..949e9a6 --- /dev/null +++ b/app/components/ambient-grain.tsx @@ -0,0 +1,30 @@ +"use client"; + +type AmbientGrainProps = { + /** Marketing pages use a slightly stronger grain than the in-app shell (Orbit default). */ + variant?: "marketing" | "app"; + className?: string; +}; + +/** Orbit-style film grain / vignette wash (`@orbit/ui/ambient-grain`). */ +export function AmbientGrain({ variant = "marketing", className = "" }: AmbientGrainProps) { + const density = + variant === "marketing" + ? "opacity-[0.22] dark:opacity-[0.35]" + : "opacity-[0.14] dark:opacity-[0.28]"; + + return ( +
+ ); +} diff --git a/app/components/form-field.tsx b/app/components/form-field.tsx new file mode 100644 index 0000000..f4e676e --- /dev/null +++ b/app/components/form-field.tsx @@ -0,0 +1,22 @@ +import type { PropsWithChildren } from "react"; + +const labelClass = + "mb-1.5 block font-mono text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground"; + +const shellClass = + "rounded-lg border border-border bg-background px-3 py-2.5 transition focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/40 focus-within:ring-offset-2 focus-within:ring-offset-background"; + +export function FormFieldLabel({ + htmlFor, + children, +}: PropsWithChildren<{ htmlFor: string }>) { + return ( + + ); +} + +export function FormFieldShell({ className = "", children }: PropsWithChildren<{ className?: string }>) { + return
{children}
; +} diff --git a/app/components/github-stars-badge.tsx b/app/components/github-stars-badge.tsx new file mode 100644 index 0000000..6965b63 --- /dev/null +++ b/app/components/github-stars-badge.tsx @@ -0,0 +1,47 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { headerChrome } from "@components/header-chrome"; + +function formatCompact(n: number) { + return Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, + }).format(n); +} + +export function GitHubStarsBadge() { + const [stars, setStars] = useState(null); + + useEffect(() => { + let cancelled = false; + fetch("https://api.github.com/repos/chronark/envshare") + .then((r) => r.json()) + .then((json: { stargazers_count?: number }) => { + if (!cancelled && typeof json.stargazers_count === "number") { + setStars(json.stargazers_count); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + const label = stars != null ? formatCompact(stars) : "—"; + + return ( + + + + + {label} + + ); +} diff --git a/app/components/header-chrome.ts b/app/components/header-chrome.ts new file mode 100644 index 0000000..d0ab777 --- /dev/null +++ b/app/components/header-chrome.ts @@ -0,0 +1,17 @@ +export const headerChrome = { + ghost: + "inline-flex h-9 shrink-0 items-center justify-center rounded-lg border border-transparent bg-transparent px-3 text-sm font-medium text-muted-foreground transition hover:border-border hover:bg-muted/60 hover:text-foreground", + ghostActive: + "inline-flex h-9 shrink-0 items-center justify-center rounded-lg border border-border bg-muted px-3 text-sm font-medium text-foreground transition", + /** Primary app routes — matches ENVSHARE wordmark (mono, caps, tracking). */ + navGhost: + "inline-flex h-9 shrink-0 items-center justify-center rounded-lg border border-transparent bg-transparent px-3 font-mono text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground transition hover:border-border hover:bg-muted/60 hover:text-foreground", + navGhostActive: + "inline-flex h-9 shrink-0 items-center justify-center rounded-lg border border-border bg-muted px-3 font-mono text-xs font-medium uppercase tracking-[0.2em] text-foreground transition", + surface: + "inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-lg border border-border bg-background px-3 text-sm font-medium text-foreground shadow-sm transition hover:bg-muted/60", + surfaceIcon: + "inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-foreground shadow-sm transition hover:bg-muted/60", + primary: + "inline-flex h-9 shrink-0 items-center justify-center rounded-lg border border-primary bg-primary px-4 font-mono text-xs font-semibold uppercase tracking-[0.18em] text-primary-foreground shadow-sm transition hover:opacity-90", +} as const; diff --git a/app/components/landing-hero.tsx b/app/components/landing-hero.tsx new file mode 100644 index 0000000..3476511 --- /dev/null +++ b/app/components/landing-hero.tsx @@ -0,0 +1,74 @@ +"use client"; + +import Link from "next/link"; +import { ParticleField } from "@components/particle-field"; + +export function LandingHero() { + return ( +
+
+ + + Open source on GitHub + +

+ Share environment variables{" "} + securely +

+

+ Your document is encrypted in your browser before being stored for a limited period of time and read + operations. Unencrypted data never leaves your browser. +

+
+ + Deploy + + + Share + + +
+
+ Simple · Secure · Private by design +
+
+ +
+
+ +
+ {/* Vignette into page bg — in dark mode this layer sits on top of the canvas and would wash out light particles, so skip it. */} +
+
+
+ ); +} diff --git a/app/components/particle-field.tsx b/app/components/particle-field.tsx new file mode 100644 index 0000000..946e987 --- /dev/null +++ b/app/components/particle-field.tsx @@ -0,0 +1,577 @@ +"use client"; + +import { + type MutableRefObject, + useEffect, + useRef, + useSyncExternalStore, +} from "react"; + +type Particle = { + ox: number; + oy: number; + x: number; + y: number; + vx: number; + vy: number; + size: number; + alpha: number; + phase: number; + /** Per-particle spring multiplier (0.75..1.25) — decorrelates arrival times during a morph. */ + springJitter: number; + /** 0 = invisible, 1 = fully painted. Eases toward 1, or toward 0 while fading. */ + appear: number; + /** Surplus particle from a prior shape — fade out and cull. */ + fading: boolean; +}; + +type ParticleTarget = { + ox: number; + oy: number; + size: number; + alpha: number; +}; + +export type ParticleFieldProps = { + src: string; + /** pixel step when sampling the source image. Lower = denser */ + sampleStep?: number; + /** alpha cutoff 0-255 for including a pixel as a particle */ + threshold?: number; + /** multiplier applied to the canvas rendering versus the sampled image */ + renderScale?: number; + /** base dot size in device pixels */ + dotSize?: number; + /** how strong the cursor repels dots */ + mouseForce?: number; + /** radius around the cursor that has repelling force, in device pixels */ + mouseRadius?: number; + /** spring constant pulling dots back to their origin */ + spring?: number; + /** viscous damping on velocity */ + damping?: number; + className?: string; + /** alignment of the particle cluster inside the canvas */ + align?: "center" | "bottom"; + /** optional color override when `adaptToTheme` is false; defaults to white */ + color?: string; + /** sample dark pixels instead of bright ones (for dark-on-light source images) */ + invert?: boolean; + /** + * When true (default), dot fill follows `html.dark` (light dots on dark, dark dots on light) + * without resampling the image — only the paint color changes, so toggling theme stays smooth. + */ + adaptToTheme?: boolean; + /** + * POC: parent bumps `current` on keydown (e.g. +0.12); field decays each frame and uses it + * to add extra drift / twinkle so typing on the auth column subtly animates the figure. + */ + typingImpulseRef?: MutableRefObject; + /** + * When true, keeps every sampled pixel above `threshold` (skips the random luminance thinning). + * Use for small fixed-size embeds where the default sparse falloff erases the figure. + */ + denseParticles?: boolean; +}; + +function subscribeDocumentDark(callback: () => void) { + const el = document.documentElement; + const mo = new MutationObserver(callback); + mo.observe(el, { attributes: true, attributeFilter: ["class"] }); + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + mq.addEventListener("change", callback); + return () => { + mo.disconnect(); + mq.removeEventListener("change", callback); + }; +} + +function getDocumentDarkSnapshot() { + return document.documentElement.classList.contains("dark"); +} + +function getServerDarkSnapshot() { + return false; +} + +const TYPING_IMPULSE_ADD = 0.14; +const TYPING_IMPULSE_CAP = 1.35; + +const SUBMIT_IMPULSE_PRIMARY = 0.52; +const SUBMIT_IMPULSE_SECOND_MS = 120; +const SUBMIT_IMPULSE_SECONDARY = 0.2; + +/** Add energy to `typingImpulseRef` (keyboard, preset chips, etc.). */ +export function pulseParticleTypingImpulse( + impulseRef: MutableRefObject, + amount = TYPING_IMPULSE_ADD, +) { + impulseRef.current = Math.min( + impulseRef.current + amount, + TYPING_IMPULSE_CAP, + ); +} + +/** + * Stronger two-beat pulse when a form is sent — primary hit plus a quick + * follow-up while the first is still decaying (reads like a soft “launch”). + */ +export function pulseParticleSubmitImpulse( + impulseRef: MutableRefObject, +) { + pulseParticleTypingImpulse(impulseRef, SUBMIT_IMPULSE_PRIMARY); + window.setTimeout(() => { + pulseParticleTypingImpulse(impulseRef, SUBMIT_IMPULSE_SECONDARY); + }, SUBMIT_IMPULSE_SECOND_MS); +} + +/** Bump `typingImpulseRef` from a `keydown` handler (used with `ParticleField` typing POC). */ +export function bumpParticleTypingImpulse( + impulseRef: MutableRefObject, + e: Pick, +) { + if (e.repeat) return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (e.key === "Tab" || e.key === "Escape") return; + pulseParticleTypingImpulse(impulseRef, TYPING_IMPULSE_ADD); +} + +function useDocumentDark() { + return useSyncExternalStore( + subscribeDocumentDark, + getDocumentDarkSnapshot, + getServerDarkSnapshot, + ); +} + +export function ParticleField({ + src, + sampleStep = 3, + threshold = 50, + renderScale = 1, + dotSize = 1.15, + mouseForce = 90, + mouseRadius = 110, + spring = 0.035, + damping = 0.86, + className, + align = "center", + color = "rgba(255, 255, 255, 0.92)", + invert = false, + adaptToTheme = true, + typingImpulseRef, + denseParticles = false, +}: ParticleFieldProps) { + const isDark = useDocumentDark(); + const fillColorRef = useRef(color); + fillColorRef.current = adaptToTheme + ? isDark + ? "rgba(255, 255, 255, 0.98)" + : "rgba(10, 12, 16, 1)" + : color; + + const canvasRef = useRef(null); + const wrapperRef = useRef(null); + const pointerRef = useRef({ x: -9999, y: -9999, active: false }); + const srcRef = useRef(src); + srcRef.current = src; + const applySrcRef = useRef<((nextSrc: string) => void) | null>(null); + + const sampleStepRef = useRef(sampleStep); + sampleStepRef.current = sampleStep; + const thresholdRef = useRef(threshold); + thresholdRef.current = threshold; + const renderScaleRef = useRef(renderScale); + renderScaleRef.current = renderScale; + const dotSizeRef = useRef(dotSize); + dotSizeRef.current = dotSize; + const mouseForceRef = useRef(mouseForce); + mouseForceRef.current = mouseForce; + const mouseRadiusRef = useRef(mouseRadius); + mouseRadiusRef.current = mouseRadius; + const springRef = useRef(spring); + springRef.current = spring; + const dampingRef = useRef(damping); + dampingRef.current = damping; + const alignRef = useRef(align); + alignRef.current = align; + const invertRef = useRef(invert); + invertRef.current = invert; + const denseParticlesRef = useRef(denseParticles); + denseParticlesRef.current = denseParticles; + + useEffect(() => { + const canvas = canvasRef.current; + const wrapper = wrapperRef.current; + if (!canvas || !wrapper) return; + + const ctx = canvas.getContext("2d", { alpha: true }); + if (!ctx) return; + + let particles: Particle[] = []; + let dpr = Math.min(window.devicePixelRatio || 1, 2); + let width = 0; + let height = 0; + let clusterW = 0; + let clusterH = 0; + let offsetX = 0; + let offsetY = 0; + let rafId = 0; + let time = 0; + let destroyed = false; + let resizeRaf = 0; + let resizeTimer: ReturnType | null = null; + let currentImage: HTMLImageElement | null = null; + let loadToken = 0; + + const ensureCanvasSize = () => { + const rect = wrapper.getBoundingClientRect(); + width = Math.max(1, Math.floor(rect.width)); + height = Math.max(1, Math.floor(rect.height)); + dpr = Math.min(window.devicePixelRatio || 1, 2); + + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + }; + + const sampleTargets = (image: HTMLImageElement): ParticleTarget[] => { + if (!image.width || !image.height) return []; + + const srcRatio = image.width / image.height; + const dstRatio = width / height; + + let drawW = width; + let drawH = height; + if (srcRatio > dstRatio) { + drawH = height; + drawW = height * srcRatio; + } else { + drawW = width; + drawH = width / srcRatio; + } + + drawW *= renderScaleRef.current; + drawH *= renderScaleRef.current; + + const sampleW = Math.max(80, Math.floor(drawW / sampleStepRef.current)); + const sampleH = Math.max(80, Math.floor(drawH / sampleStepRef.current)); + + const off = document.createElement("canvas"); + off.width = sampleW; + off.height = sampleH; + const offCtx = off.getContext("2d", { willReadFrequently: true }); + if (!offCtx) return []; + offCtx.drawImage(image, 0, 0, sampleW, sampleH); + const data = offCtx.getImageData(0, 0, sampleW, sampleH).data; + + const cellW = drawW / sampleW; + const cellH = drawH / sampleH; + + clusterW = drawW; + clusterH = drawH; + offsetX = (width - clusterW) / 2; + offsetY = + alignRef.current === "bottom" + ? height - clusterH - Math.min(40, height * 0.04) + : (height - clusterH) / 2; + + const thresholdV = thresholdRef.current; + const invertV = invertRef.current; + const denseV = denseParticlesRef.current; + const dotSizeV = dotSizeRef.current; + + const targets: ParticleTarget[] = []; + for (let y = 0; y < sampleH; y++) { + for (let x = 0; x < sampleW; x++) { + const idx = (y * sampleW + x) * 4; + const r = data[idx]; + const g = data[idx + 1]; + const b = data[idx + 2]; + const a = data[idx + 3]; + const rawBrightness = (r + g + b) / 3; + const brightness = invertV ? 255 - rawBrightness : rawBrightness; + if (a < 200 || brightness < thresholdV) continue; + + const lum = brightness / 255; + if (!denseV) { + const keep = + lum > 0.8 + ? true + : lum > 0.5 + ? Math.random() < 0.85 + : lum > 0.25 + ? Math.random() < 0.55 + : Math.random() < 0.28; + if (!keep) continue; + } + + const px = (offsetX + x * cellW + cellW / 2) * dpr; + const py = (offsetY + y * cellH + cellH / 2) * dpr; + + targets.push({ + ox: px, + oy: py, + size: (dotSizeV + lum * 0.9) * dpr, + alpha: 0.35 + lum * 0.6, + }); + } + } + return targets; + }; + + const randomSpringJitter = () => 0.9 + Math.random() * 0.2; + + const buildFresh = (image: HTMLImageElement) => { + if (!image.width || !image.height) return; + ensureCanvasSize(); + const targets = sampleTargets(image); + particles = targets.map((t) => ({ + ox: t.ox, + oy: t.oy, + x: t.ox + (Math.random() - 0.5) * 40, + y: t.oy + (Math.random() - 0.5) * 40, + vx: 0, + vy: 0, + size: t.size, + alpha: t.alpha, + phase: Math.random() * Math.PI * 2, + springJitter: randomSpringJitter(), + appear: 1, + fading: false, + })); + }; + + const shuffleIndices = (n: number): number[] => { + const arr = new Array(n); + for (let i = 0; i < n; i++) arr[i] = i; + for (let i = n - 1; i > 0; i--) { + const j = (Math.random() * (i + 1)) | 0; + const tmp = arr[i]; + arr[i] = arr[j]; + arr[j] = tmp; + } + return arr; + }; + + const morphTo = (image: HTMLImageElement) => { + if (!image.width || !image.height) return; + if (particles.length === 0) { + buildFresh(image); + return; + } + ensureCanvasSize(); + const targets = sampleTargets(image); + + const n = particles.length; + const m = targets.length; + const matched = Math.min(n, m); + const pOrder = shuffleIndices(n); + const tOrder = shuffleIndices(m); + + for (let k = 0; k < matched; k++) { + const p = particles[pOrder[k]]; + const t = targets[tOrder[k]]; + p.ox = t.ox; + p.oy = t.oy; + p.size = t.size; + p.alpha = t.alpha; + p.fading = false; + p.springJitter = randomSpringJitter(); + } + + for (let k = matched; k < n; k++) { + particles[pOrder[k]].fading = true; + } + + for (let k = matched; k < m; k++) { + const t = targets[tOrder[k]]; + const angle = Math.random() * Math.PI * 2; + const dist = (20 + Math.random() * 40) * dpr; + particles.push({ + ox: t.ox, + oy: t.oy, + x: t.ox + Math.cos(angle) * dist, + y: t.oy + Math.sin(angle) * dist, + vx: 0, + vy: 0, + size: t.size, + alpha: t.alpha, + phase: Math.random() * Math.PI * 2, + springJitter: randomSpringJitter(), + appear: 0, + fading: false, + }); + } + }; + + const render = () => { + if (destroyed) return; + time += 0.016; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = fillColorRef.current; + + const mouseForceV = mouseForceRef.current; + const mouseRadiusV = mouseRadiusRef.current; + const springV = springRef.current; + const dampingV = dampingRef.current; + + const px = pointerRef.current.x * dpr; + const py = pointerRef.current.y * dpr; + const mr = mouseRadiusV * dpr; + const mr2 = mr * mr; + + let typing = typingImpulseRef?.current ?? 0; + if (typingImpulseRef && typing > 1e-4) { + typingImpulseRef.current *= 0.93; + } + const typingBoost = 1 + typing * 10; + const rippleCx = (offsetX + clusterW * 0.5) * dpr; + const rippleCy = (offsetY + clusterH * 0.48) * dpr; + + let writeIdx = 0; + for (let i = 0; i < particles.length; i++) { + const p = particles[i]; + + const dxo = p.ox - p.x; + const dyo = p.oy - p.y; + const s = springV * p.springJitter; + p.vx += dxo * s; + p.vy += dyo * s; + + if (pointerRef.current.active) { + const dx = p.x - px; + const dy = p.y - py; + const d2 = dx * dx + dy * dy; + if (d2 < mr2 && d2 > 0.0001) { + const d = Math.sqrt(d2); + const force = (1 - d / mr) * mouseForceV; + p.vx += (dx / d) * force * 0.04; + p.vy += (dy / d) * force * 0.04; + } + } + + const drift = Math.sin(time * 0.8 + p.phase) * 0.08; + p.vx += drift * 0.05 * typingBoost; + p.vy += Math.cos(time * 0.9 + p.phase) * 0.04 * typingBoost; + + if (typing > 1e-4) { + p.vx += (Math.random() - 0.5) * typing * 2.8; + p.vy += (Math.random() - 0.5) * typing * 2.8; + const rdx = p.x - rippleCx; + const rdy = p.y - rippleCy; + const rd = Math.sqrt(rdx * rdx + rdy * rdy) + 0.5; + const ripple = (typing * 22 * dpr) / rd; + p.vx += (rdx / rd) * ripple * 0.018; + p.vy += (rdy / rd) * ripple * 0.018; + } + + p.vx *= dampingV; + p.vy *= dampingV; + p.x += p.vx; + p.y += p.vy; + + const appearTarget = p.fading ? 0 : 1; + p.appear += (appearTarget - p.appear) * 0.08; + + if (p.fading && p.appear < 0.02) { + continue; + } + + const twinkle = + 0.85 + + Math.sin(time * (1.4 + typing * 2.2) + p.phase) * + (0.15 + typing * 0.35); + ctx.globalAlpha = p.alpha * p.appear * twinkle; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + + if (writeIdx !== i) particles[writeIdx] = p; + writeIdx++; + } + if (writeIdx !== particles.length) particles.length = writeIdx; + ctx.globalAlpha = 1; + rafId = requestAnimationFrame(render); + }; + + const onPointerMove = (e: PointerEvent) => { + const rect = wrapper.getBoundingClientRect(); + pointerRef.current.x = e.clientX - rect.left; + pointerRef.current.y = e.clientY - rect.top; + pointerRef.current.active = true; + }; + const onPointerLeave = () => { + pointerRef.current.active = false; + pointerRef.current.x = -9999; + pointerRef.current.y = -9999; + }; + + const ro = new ResizeObserver(() => { + if (resizeRaf) cancelAnimationFrame(resizeRaf); + resizeRaf = requestAnimationFrame(() => { + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + if (currentImage) buildFresh(currentImage); + }, 120); + }); + }); + + const loadAndApply = (nextSrc: string, asMorph: boolean) => { + const token = ++loadToken; + const image = new Image(); + image.crossOrigin = "anonymous"; + image.decoding = "async"; + image.onload = () => { + if (destroyed || token !== loadToken) return; + currentImage = image; + if (asMorph) morphTo(image); + else buildFresh(image); + }; + image.src = nextSrc; + }; + + applySrcRef.current = (nextSrc: string) => loadAndApply(nextSrc, true); + + ro.observe(wrapper); + rafId = requestAnimationFrame(render); + + loadAndApply(srcRef.current, false); + + wrapper.addEventListener("pointermove", onPointerMove); + wrapper.addEventListener("pointerleave", onPointerLeave); + + return () => { + destroyed = true; + cancelAnimationFrame(rafId); + if (resizeRaf) cancelAnimationFrame(resizeRaf); + if (resizeTimer) clearTimeout(resizeTimer); + ro.disconnect(); + wrapper.removeEventListener("pointermove", onPointerMove); + wrapper.removeEventListener("pointerleave", onPointerLeave); + applySrcRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const lastAppliedSrcRef = useRef(src); + useEffect(() => { + if (lastAppliedSrcRef.current === src) return; + lastAppliedSrcRef.current = src; + applySrcRef.current?.(src); + }, [src]); + + return ( +
+ +
+ ); +} diff --git a/app/components/stats.tsx b/app/components/stats.tsx index 31d74bc..22762a0 100644 --- a/app/components/stats.tsx +++ b/app/components/stats.tsx @@ -8,7 +8,7 @@ export const Stats = asyncComponent(async () => { .pipeline() .get("envshare:metrics:reads") .get("envshare:metrics:writes") - .exec<[number, number]>(); + .exec<[number | null, number | null]>(); const stars = await fetch("https://api.github.com/repos/chronark/envshare") .then((res) => res.json()) .then((json) => json.stargazers_count as number); @@ -16,11 +16,11 @@ export const Stats = asyncComponent(async () => { const stats = [ { label: "Documents Encrypted", - value: writes, + value: writes ?? 0, }, { label: "Documents Decrypted", - value: reads, + value: reads ?? 0, }, ] satisfies { label: string; value: number }[]; @@ -32,17 +32,14 @@ export const Stats = asyncComponent(async () => { } return ( -
-
    +
    +
      {stats.map(({ label, value }) => ( -
    • -
      +
    • +
      {Intl.NumberFormat("en-US", { notation: "compact" }).format(value)}
      -
      {label}
      +
      {label}
    • ))}
    @@ -50,8 +47,6 @@ export const Stats = asyncComponent(async () => { ); }); -// stupid hack to make "server components" actually work with components -// https://www.youtube.com/watch?v=h_9Vx6kio2s function asyncComponent(fn: (arg: T) => Promise): (arg: T) => R { return fn as (arg: T) => R; } diff --git a/app/components/testimony.tsx b/app/components/testimony.tsx index 757a953..b0fd9ba 100644 --- a/app/components/testimony.tsx +++ b/app/components/testimony.tsx @@ -1,15 +1,23 @@ "use client"; -import Image from "next/image"; import Link from "next/link"; -import { Props } from "next/script"; import React, { PropsWithChildren } from "react"; +function twitterAvatarUrl(handle: string) { + const h = handle.startsWith("@") ? handle.slice(1) : handle; + return `https://unavatar.io/twitter/${encodeURIComponent(h)}`; +} + const TwitterHandle: React.FC = ({ children }) => { - return {children}; + return {children}; }; const Author: React.FC> = ({ children, href }) => ( - + {children} ); @@ -19,7 +27,7 @@ const Title: React.FC> = ({ children, href } target="_blank" rel="noopener noreferrer" href={href} - className="text-sm duration-150 text-zinc-500 hover:text-zinc-300" + className="text-sm text-muted-foreground transition hover:text-foreground" > {children} @@ -32,7 +40,8 @@ export const Testimonials = () => { author: { name: React.ReactNode; title?: React.ReactNode; - image: string; + twitterUsername?: string; + avatarUrl?: string; }; }[] = [ { @@ -55,7 +64,7 @@ export const Testimonials = () => { author: { name: Frederik Markor, title: CEO @discreet, - image: "https://pbs.twimg.com/profile_images/1438061314010664962/NecuMIGR_400x400.jpg", + twitterUsername: "FrederikMarkor", }, }, { @@ -77,7 +86,7 @@ export const Testimonials = () => { author: { name: Steven Tey, title: Senior Developer Advocate at Vercel, - image: "https://pbs.twimg.com/profile_images/1506792347840888834/dS-r50Je_400x400.jpg", + twitterUsername: "steventey", }, }, { @@ -92,33 +101,66 @@ export const Testimonials = () => { link: "https://twitter.com/DesignSiddharth/status/1615293209164546048", author: { name: @DesignSiddharth, - image: "https://pbs.twimg.com/profile_images/1613772710009765888/MbSblJYf_400x400.jpg", + twitterUsername: "DesignSiddharth", }, }, ]; return (
    -
      - {posts.map((post, i) => ( -
      + {posts.map((post, i) => { + const avatarSrc = + post.author.avatarUrl ?? + (post.author.twitterUsername ? twitterAvatarUrl(post.author.twitterUsername) : null); + return ( +
    • - - {post.content} + + + “ + +
      + {post.content} +
      -
      -
      -
      {post.author.name}
      -
      {post.author.title}
      +
      +
      + {avatarSrc ? ( + + ) : ( +
      + ? +
      + )}
      -
      - +
      +
      {post.author.name}
      + {post.author.title ? ( +
      {post.author.title}
      + ) : null}
      -
      - ))} +
    • + ); + })}
    ); diff --git a/app/components/theme-provider.tsx b/app/components/theme-provider.tsx new file mode 100644 index 0000000..4c85b76 --- /dev/null +++ b/app/components/theme-provider.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { ThemeProvider as NextThemesProvider } from "next-themes"; + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/components/theme-toggle.tsx b/app/components/theme-toggle.tsx new file mode 100644 index 0000000..f30958a --- /dev/null +++ b/app/components/theme-toggle.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { MoonIcon, SunIcon } from "@heroicons/react/24/outline"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; +import { headerChrome } from "@components/header-chrome"; + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const isDark = mounted && resolvedTheme === "dark"; + + return ( + + ); +} diff --git a/app/components/title.tsx b/app/components/title.tsx index b9b7f3c..f1fd905 100644 --- a/app/components/title.tsx +++ b/app/components/title.tsx @@ -2,7 +2,7 @@ import React, { PropsWithChildren } from "react"; export const Title: React.FC = ({ children }): JSX.Element => { return ( -

    +

    {children}

    ); diff --git a/app/deploy/page.tsx b/app/deploy/page.tsx index b515144..93a2293 100644 --- a/app/deploy/page.tsx +++ b/app/deploy/page.tsx @@ -1,85 +1,92 @@ "use client"; + import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; import Link from "next/link"; import { Title } from "@components/title"; import React from "react"; + const steps: { name: string; - description: string | React.ReactNode; - cta?: React.ReactNode; + description: React.ReactNode; + cta?: { href: string; label: string }; }[] = [ { name: "Create a new Redis database on Upstash", description: ( <> - Upstash offers a serverless Redis database with a generous free tier of up to 10,000 requests per day. That's - more than enough. + Upstash offers a serverless Redis database with a generous free tier of up to 10,000 requests per day. + {"That's"} more than enough. +

    Click the button below to sign up and create a new Redis database on Upstash. ), - cta: ( - - Create Database - - - ), + cta: { href: "https://console.upstash.com/redis", label: "Create database" }, }, { name: "Copy the REST connection credentials", description: (

    - After creating the database, scroll to the bottom and make a note of UPSTASH_REDIS_REST_URL and{" "} - UPSTASH_REDIS_REST_TOKEN, you need them in the next step + After creating the database, scroll to the bottom and make a note of{" "} + UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN — you need them in the next + step.

    ), }, { name: "Deploy to Vercel", - description: "Deploy the app to Vercel and paste the connection credentials into the environment variables.", - cta: ( - - Deploy - - - ), + description: + "Deploy the app to Vercel and paste the connection credentials into the environment variables for your project.", + cta: { + href: "https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fchronark%2Fenvshare&env=UPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKEN&demo-title=Share%20Environment%20Variables%20Securely&demo-url=https%3A%2F%2Fcryptic.vercel.app", + label: "Deploy to Vercel", + }, }, ]; +const descriptionCode = + "[&_code]:rounded-md [&_code]:border [&_code]:border-border [&_code]:bg-muted/60 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[13px] [&_code]:font-medium [&_code]:text-foreground"; + +const ctaClass = + "inline-flex h-10 w-full max-w-sm items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 font-mono text-xs font-semibold uppercase tracking-[0.15em] text-foreground shadow-sm transition hover:bg-muted/60"; + export default function Deploy() { return ( -
    +
    Deploy EnvShare for Free -

    - You can deploy your own hosted version of EnvShare, you just need an Upstash and Vercel account. +

    + You can deploy your own hosted version of EnvShare — you only need Upstash and Vercel accounts.

    -
      - {steps.map((step, stepIdx) => ( -
    1. -