Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 175 additions & 79 deletions apps/extension/entrypoints/popup/App.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,36 @@
import { ArrowUpRight, Circle, MousePointerClick, Square } from "lucide-react";
import { useEffect, useState } from "react";
import {
ArrowUpRight,
MousePointerClick,
Square,
TriangleAlert,
} from "lucide-react";
import { browser } from "wxt/browser";
import { MadeByRustrak } from "@/components/brand/made-by-rustrak";
import { OpenShowcaseWordmark } from "@/components/brand/openshowcase-wordmark";
import { Button } from "@/components/ui/button";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { sendMessage } from "@/lib/messaging";
import { RecDot } from "./components/RecDot";
import { RecentRecordings } from "./components/RecentRecordings";
import { RecordGlyph } from "./components/RecordGlyph";
import { TabCard } from "./components/TabCard";
import { formatElapsed, isRecordableUrl } from "./core/format";
import {
defaultRecordingSessionState,
type RecordingSessionState,
sendMessage,
} from "@/lib/messaging";
import { recordingSession } from "@/lib/recording-storage";
useActiveTab,
useNow,
useRecentRecordings,
useRecordingSession,
useRecordingStart,
useTab,
} from "./hooks/use-popup-data";

const RECENT_LIMIT = 3;

function App() {
useColorScheme();
const [state, setState] = useState<RecordingSessionState>(
defaultRecordingSessionState,
);

useEffect(() => {
recordingSession.getValue().then(setState);
return recordingSession.watch((newState) => setState(newState));
}, []);
const session = useRecordingSession();
const activeTab = useActiveTab();
const recent = useRecentRecordings(RECENT_LIMIT);

async function send(type: "startRecording" | "stopRecording") {
await sendMessage(type, undefined).catch((error) => {
Expand All @@ -35,79 +44,40 @@ function App() {
window.close();
}

async function openEditor() {
if (!state.recordingId) return;
async function openEditor(recordingId: string) {
await browser.tabs.create({
url: browser.runtime.getURL(
`/editor.html?recordingId=${state.recordingId}`,
),
url: browser.runtime.getURL(`/editor.html?recordingId=${recordingId}`),
});
window.close();
}

const hasFinishedRecording = !state.active && Boolean(state.recordingId);

return (
<div className="flex w-[300px] flex-col gap-4 bg-background p-4 text-foreground">
<header className="flex h-6 items-center justify-between">
<div className="flex w-[320px] flex-col bg-window text-foreground">
<header className="flex h-11 items-center justify-between px-4">
<OpenShowcaseWordmark className="h-[14px] w-auto" />
{state.active && (
<span className="flex items-center gap-1.5 rounded-full bg-destructive/10 px-2 py-0.5 text-2xs font-semibold tracking-wide text-destructive">
<span className="relative flex size-1.5">
<span className="absolute inset-0 animate-ping rounded-full bg-destructive opacity-60 motion-reduce:hidden" />
<span className="relative size-1.5 rounded-full bg-destructive" />
</span>
REC
</span>
)}
{session.active && <ElapsedChip recordingId={session.recordingId} />}
</header>

{state.active ? (
<div className="flex flex-col gap-3 animate-in fade-in-0 duration-200">
<div className="flex items-center gap-3 rounded-xl bg-muted px-3 py-3">
<span className="flex size-9 items-center justify-center rounded-lg bg-background text-foreground shadow-card">
<MousePointerClick className="size-4" />
</span>
<div className="flex flex-col">
<span className="text-ui font-semibold tabular-nums">
{state.clickCount ?? 0}{" "}
{state.clickCount === 1 ? "step" : "steps"}
</span>
<span className="text-2xs text-muted-foreground">
Every click becomes a step
</span>
</div>
</div>
<Button
className="h-10 w-full gap-2 bg-foreground text-background hover:bg-foreground/85"
onClick={() => send("stopRecording")}
>
<Square className="size-3 fill-current" /> Stop & edit
</Button>
</div>
) : (
<div className="flex flex-col gap-3 animate-in fade-in-0 duration-200">
<p className="text-ui leading-relaxed text-muted-foreground">
Use this tab normally. Clicks become steps, scrolling and typing
become short clips — the editor opens when you stop.
</p>
<Button className="h-10 w-full gap-2" onClick={startRecording}>
<Circle className="size-3 fill-destructive text-destructive" />{" "}
Record this tab
</Button>
{hasFinishedRecording && (
<Button
variant="ghost"
className="h-9 w-full justify-between text-xs text-muted-foreground"
onClick={openEditor}
>
Open last recording
<ArrowUpRight className="size-3.5" />
</Button>
)}
</div>
)}
<main className="flex flex-col gap-3 px-3 pb-3">
{session.active ? (
<RecordingView
tabId={session.tabId}
activeTabId={activeTab?.id}
clickCount={session.clickCount ?? 0}
onStop={() => send("stopRecording")}
/>
) : (
<IdleView
tab={activeTab}
onStart={startRecording}
recent={
<RecentRecordings recordings={recent} onOpen={openEditor} />
}
/>
)}
</main>

<footer className="-mx-4 -mb-4 flex items-center justify-between border-t border-border/60 px-4 py-2.5">
<footer className="flex items-center justify-between border-t border-border/60 bg-background px-4 py-2.5">
<span className="text-2xs text-muted-foreground">
Open source · self-hosted
</span>
Expand All @@ -117,4 +87,130 @@ function App() {
);
}

function IdleView({
tab,
onStart,
recent,
}: {
tab: ReturnType<typeof useActiveTab>;
onStart: () => void;
recent: React.ReactNode;
}) {
const loading = tab === undefined;
const recordable = isRecordableUrl(tab?.url ?? undefined);

return (
<div className="flex flex-col gap-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200 ease-(--ease-out-strong)">
<div className="flex flex-col gap-2 rounded-2xl bg-background p-2 shadow-card">
<TabCard tab={tab} className="shadow-none" />
{!loading && !recordable ? (
<p className="flex items-start gap-2 rounded-lg bg-muted px-2.5 py-2 text-2xs leading-relaxed text-muted-foreground">
<TriangleAlert className="mt-px size-3.5 shrink-0" />
Chrome doesn't let extensions record this page. Open a website to
start.
</p>
) : (
<p className="flex items-start gap-2 px-2.5 py-1 text-2xs leading-relaxed text-muted-foreground">
<MousePointerClick className="mt-0.5 size-3.5 shrink-0" />
Clicks become steps. Scrolling and typing become short clips.
</p>
)}
<Button
size="lg"
variant={recordable || loading ? "default" : "secondary"}
className="h-10 w-full gap-2 rounded-xl text-ui font-semibold tracking-[-0.005em] shadow-card transition-[background-color,scale] duration-150 ease-(--ease-out-strong) hover:bg-primary/90 active:scale-[0.985] disabled:shadow-none"
disabled={loading || !recordable}
onClick={onStart}
>
<RecordGlyph />
Record this tab
</Button>
</div>
{recent}
</div>
);
}

function RecordingView({
tabId,
activeTabId,
clickCount,
onStop,
}: {
tabId: number | undefined;
activeTabId: number | undefined;
clickCount: number;
onStop: () => void;
}) {
const tab = useTab(tabId);
const elsewhere =
tab?.id != null && activeTabId != null && tab.id !== activeTabId;

async function goToTab() {
if (tab?.id == null) return;
await browser.tabs.update(tab.id, { active: true });
if (tab.windowId != null) {
await browser.windows.update(tab.windowId, { focused: true });
}
window.close();
}

return (
<div className="flex flex-col gap-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200 ease-(--ease-out-strong)">
<div className="flex flex-col gap-2 rounded-2xl bg-background p-2 shadow-card">
<TabCard
tab={tab}
recording
className="shadow-none"
trailing={
elsewhere && (
<Button
variant="ghost"
size="icon-sm"
aria-label="Go to the recorded tab"
onClick={goToTab}
>
<ArrowUpRight />
</Button>
)
}
/>
<div className="flex items-center justify-between rounded-lg bg-muted px-3 py-2">
<span className="flex items-center gap-2 text-2xs text-muted-foreground">
<MousePointerClick className="size-3.5" />
Steps captured
</span>
<span
key={clickCount}
className="text-ui font-semibold tabular-nums animate-in fade-in-0 zoom-in-90 duration-200"
>
{clickCount}
</span>
</div>
<Button
size="lg"
className="h-10 w-full gap-2 rounded-xl bg-foreground text-ui font-semibold tracking-[-0.005em] text-background shadow-card transition-[background-color,scale] duration-150 ease-(--ease-out-strong) hover:bg-foreground/85 active:scale-[0.985]"
onClick={onStop}
>
<Square className="size-3 fill-current" /> Stop & edit
</Button>
</div>
<p className="px-1 text-center text-2xs text-muted-foreground">
The editor opens as soon as you stop.
</p>
</div>
);
}

function ElapsedChip({ recordingId }: { recordingId: string | undefined }) {
const start = useRecordingStart(recordingId);
const now = useNow(true);
return (
<span className="flex items-center gap-1.5 rounded-full bg-destructive/10 py-0.5 pr-2 pl-1.5 text-2xs font-semibold text-destructive tabular-nums">
<RecDot className="size-1.5" />
{start == null ? "REC" : formatElapsed(now - start)}
</span>
);
}

export default App;
10 changes: 10 additions & 0 deletions apps/extension/entrypoints/popup/components/RecDot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { cn } from "@/lib/utils";

export function RecDot({ className }: { className?: string }) {
return (
<span className={cn("relative flex size-2 shrink-0", className)}>
<span className="absolute inset-0 animate-ping rounded-full bg-destructive opacity-60 motion-reduce:hidden" />
<span className="relative size-full rounded-full bg-destructive" />
</span>
);
}
47 changes: 47 additions & 0 deletions apps/extension/entrypoints/popup/components/RecentRecordings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ChevronRight, Film } from "lucide-react";
import { displayHost, formatElapsed, formatRelativeTime } from "../core/format";
import type { RecentRecording } from "../hooks/use-popup-data";

export function RecentRecordings({
recordings,
onOpen,
}: {
recordings: RecentRecording[];
onOpen: (id: string) => void;
}) {
if (recordings.length === 0) return null;
const now = Date.now();
return (
<section className="flex flex-col gap-1">
<h2 className="px-1 text-2xs font-medium text-muted-foreground">
Recent recordings
</h2>
<ul className="-mx-1 flex flex-col">
{recordings.map((recording) => (
<li key={recording.id}>
<button
type="button"
onClick={() => onOpen(recording.id)}
className="group flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-background group-hover:text-foreground">
<Film className="size-3.5" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-xs font-medium">
{displayHost(recording.pageUrl) || "Recording"}
</span>
<span className="text-2xs text-muted-foreground tabular-nums">
{formatRelativeTime(recording.createdAt, now)}
{recording.durationSec != null &&
` · ${formatElapsed(recording.durationSec * 1000)}`}
</span>
</span>
<ChevronRight className="size-3.5 text-muted-foreground opacity-0 transition-[opacity,translate] duration-150 ease-(--ease-out-strong) group-hover:translate-x-0.5 group-hover:opacity-100" />
</button>
</li>
))}
</ul>
</section>
);
}
41 changes: 41 additions & 0 deletions apps/extension/entrypoints/popup/components/RecordGlyph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { cn } from "@/lib/utils";

/**
* Record mark: a dot with a ripple that keeps expanding into the ring, drawn in
* `currentColor` so it follows the button. Static under `prefers-reduced-motion`.
*/
export function RecordGlyph({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className={cn("size-4 shrink-0 overflow-visible", className)}
>
<circle
cx="8"
cy="8"
r="6.75"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
opacity="0.3"
/>
<circle
cx="8"
cy="8"
r="6.75"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="record-ripple"
/>
<circle
cx="8"
cy="8"
r="3.5"
fill="currentColor"
className="record-dot"
/>
</svg>
);
}
Loading
Loading