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
54 changes: 54 additions & 0 deletions apps/client/src/components/lookout/EditorModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useEffect } from "react";
import { TimelapseEditor, setAccentColor } from "@lookout/react";

import { Modal, ModalHeader, ModalContent } from "@/components/layout/Modal";

// Lapse's brand red (mirrors --color-red in globals.css). Applied to the Lookout SDK's
// document-root accent variables so the editor matches the rest of the app.
const LAPSE_ACCENT = "#ec3750";

/**
* The Lookout cut/edit step, wrapped in a Lapse-styled modal. Used right after a
* recording stops, and again by the publish page to resume an interrupted edit while
* the session's edit hold is still live.
*
* `onDone` fires when the user is finished with the editor — cuts applied or the
* editor dismissed. Either way the session publishes (the edit hold is a lease, and
* leaving the editor is the decision to publish), so callers should continue to the
* publish flow.
*/
export function EditorModal({ token, apiBaseUrl, onDone }: {
token: string;
apiBaseUrl: string;
onDone: () => void;
}) {
// The editor is the only SDK-rendered surface in Lapse, so the accent lives here
// rather than on LookoutProvider. Set on the document root (the editor portals to
// <body>), restored on unmount so the accent never leaks past the editor.
useEffect(() => {
setAccentColor(LAPSE_ACCENT, null);
return () => setAccentColor(null, null);
}, []);

return (
<Modal isOpen size="FULL">
<ModalHeader
icon="edit"
title="Edit your timelapse"
description="Trim out any parts you don't want to keep."
showCloseButton
onClose={onDone}
/>
<ModalContent className="p-0 flex-1 min-h-0">
<div className="h-[70vh] min-h-125">
<TimelapseEditor
token={token}
apiBaseUrl={apiBaseUrl}
onApplied={onDone}
onCancel={onDone}
/>
</div>
</ModalContent>
</Modal>
);
}
97 changes: 85 additions & 12 deletions apps/client/src/components/lookout/LookoutRecorder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ import {
removeStoredSession,
} from "@/components/lookout/sessions";
import RootLayout from "@/components/layout/RootLayout";
import { EditorModal } from "@/components/lookout/EditorModal";
import { Modal, ModalHeader, ModalContent } from "@/components/layout/Modal";
import { LoadingModal } from "@/components/layout/LoadingModal";
import { ErrorModal } from "@/components/layout/ErrorModal";
import { CopyField } from "@/components/ui/CopyField";
import { PillControlButton } from "@/components/ui/PillControlButton";

/** The deep link that hands a recording session to the Lookout desktop app. */
function desktopSessionLink(token: string): string {
return `lookout://session?token=${token}`;
}

function formatTrackedTime(totalSeconds: number): string {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
Expand Down Expand Up @@ -624,7 +631,7 @@ export default function LookoutRecorder() {

function startWithConfig(cfg: LookoutSessionConfig) {
if (selectedMode === "desktop") {
window.location.href = `lookout://session?token=${cfg.lookoutToken}`;
window.location.href = desktopSessionLink(cfg.lookoutToken);
setDesktopLaunched(true);
return;
}
Expand Down Expand Up @@ -676,18 +683,24 @@ export default function LookoutRecorder() {
}

if (desktopLaunched && config) {
const sessionLink = desktopSessionLink(config.lookoutToken);

return (
<RootLayout showHeader={false}>
<div className="flex w-screen h-screen items-center justify-center p-8">
<div className="min-h-screen flex items-center justify-center px-4 py-12">
<div className="flex flex-col items-center text-center gap-6 max-w-md">
<img src="/images/lookout-icon.png" alt="Lookout" className="w-16 h-16 rounded-2xl" />
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold">Opening Lookout</h1>
<p className="text-muted">
The Lookout app should have opened on your desktop. If nothing happened, you may need to install it first.
</p>
<p className="text-muted">
We&apos;ll bring you back here to publish once your timelapse is done.
</p>
</div>
<div className="flex flex-col gap-3 w-full">
{/* Installing is the way out of this screen for most people who end up reading it, so it leads. */}
<a
href="https://lookout.hackclub.com/"
target="_blank"
Expand All @@ -696,12 +709,35 @@ export default function LookoutRecorder() {
>
Get Lookout
</a>
<button
onClick={() => setDesktopLaunched(false)}
className="w-full border border-slate hover:bg-darkless font-bold py-3 px-6 rounded-lg transition-colors cursor-pointer"
>
Go back
</button>

<div className="flex items-center gap-3 w-full">
<button
onClick={() => setDesktopLaunched(false)}
title="Go back"
aria-label="Go back"
className="shrink-0 flex items-center justify-center w-12 h-12 border border-slate hover:bg-darkless rounded-lg transition-colors cursor-pointer"
>
<Icon glyph="back" size={24} />
</button>
<a
href={sessionLink}
className="flex-1 border border-slate hover:bg-darkless font-bold py-3 px-6 rounded-lg transition-colors text-center"
>
Open Lookout again
</a>
</div>
</div>

{/*
The deep link, in the flesh. Lookout has a "paste a lookout:// link" box precisely for when the
handoff doesn't fire on its own (a browser that swallows unknown schemes, a fresh install that
hasn't registered one yet), and this is the only place the link exists to be copied from.
*/}
<div className="flex flex-col gap-2 w-full pt-2 border-t border-slate">
<p className="text-sm text-muted">
Still nothing? Copy this link and paste it into Lookout.
</p>
<CopyField value={sessionLink} label="Recording session link" />
</div>
</div>
</div>
Expand Down Expand Up @@ -731,6 +767,8 @@ export default function LookoutRecorder() {
>
<LapseRecorder
draftId={config.draftId}
lookoutToken={config.lookoutToken}
apiBaseUrl={config.lookoutApiBaseUrl}
onShareFailed={() => { releasePendingStream(); setCaptureMode(null); setCameraDeviceId(null); }}
onBrowserError={(message) => {
// Browser capture failed — return to the selector (the "picker") and remember why,
Expand Down Expand Up @@ -868,15 +906,22 @@ function CameraPreviewVideo({ stream }: { stream: MediaStream }) {
// it as "go back to mode selection" rather than a scary error — see the error effect.
const SCREEN_CANCELLED_MESSAGE = "Screen sharing was cancelled.";

function LapseRecorder({ draftId, onShareFailed, onBrowserError }: {
function LapseRecorder({ draftId, lookoutToken, apiBaseUrl, onShareFailed, onBrowserError }: {
draftId: string;
lookoutToken: string;
apiBaseUrl: string;
onShareFailed: () => void;
onBrowserError: (message: string) => void;
}) {
const router = useRouter();
const { state, actions } = useLookout();
const [error, setError] = useState<string | null>(null);
const [captureError, setCaptureError] = useState<string | null>(null);
// Once recording stops we offer the cut/edit step before handing off to the publish
// page. `editorDone` flips when the user saves or dismisses the editor, which is what
// actually releases them to publish.
const [editorOpen, setEditorOpen] = useState(false);
const [editorDone, setEditorDone] = useState(false);
const screenStarted = useRef(false);
const isCamera = state.captureMode === "camera";

Expand Down Expand Up @@ -937,10 +982,18 @@ function LapseRecorder({ draftId, onShareFailed, onBrowserError }: {
}, 30 * 1000);

useEffect(() => {
if (state.status === "stopped" || state.status === "compiling" || state.status === "complete") {
const recordingEnded = state.status === "stopped" || state.status === "compiling" || state.status === "complete";
if (!recordingEnded) return;

// The user has finished with the editor (saved cuts or dismissed it) — hand off to
// the existing publish page. Without a token to edit against, skip straight there so
// the flow never gets stuck.
if (editorDone || !lookoutToken) {
router.push(`/timelapse/publish/${draftId}`);
} else {
setEditorOpen(true);
}
}, [state.status, draftId, router]);
}, [state.status, editorDone, lookoutToken, draftId, router]);

function handleStartSharing() {
actions.startSharing().catch((err) =>
Expand All @@ -958,12 +1011,32 @@ function LapseRecorder({ draftId, onShareFailed, onBrowserError }: {

async function stopRecording() {
try {
await actions.stop();
// `edit: true` asks the server to hold the session open for editing after stop
// instead of compiling straight to `complete`. Without it the cut/edit step below
// gets a `not_ready` session ("isn't available for editing").
await actions.stop({ edit: true });
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to stop recording");
}
}

// After recording stops, the cut/edit step is offered in a Lapse-styled modal. This
// must precede the capture-state branches below (once stopped, `state.isSharing` is
// false, which would otherwise render the empty placeholder). Dismissing or saving
// sets `editorDone`, which the effect above turns into the publish-page handoff — the
// editor itself publishes the (possibly cut) session; we just continue Lapse's flow.
if (editorOpen && lookoutToken) {
return (
<RootLayout showHeader={false}>
<EditorModal
token={lookoutToken}
apiBaseUrl={apiBaseUrl}
onDone={() => { setEditorOpen(false); setEditorDone(true); }}
/>
</RootLayout>
);
}

// Camera failures show an inline modal; screen failures are handled by the effect above,
// which sends the user back to the selector with the error recorded.
if (captureError) {
Expand Down
96 changes: 96 additions & 0 deletions apps/client/src/components/ui/CopyField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import Icon from "@hackclub/icons";

/**
* A read-only field holding something the user has to take somewhere else - a link, a token - with a copy button
* beside it. The value stays visible and selects itself on focus, because copying needs a secure context and the
* user's permission, and a button that silently does nothing is worse than a field they can select by hand.
*/
export function CopyField({ value, label, className }: {
value: string;

/** Describes the value for screen readers, e.g. "Recording session link". */
label: string;

className?: string;
}) {
const [copied, setCopied] = useState(false);
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

// Copying right before this unmounts would otherwise leave the timer to set state on a gone component.
useEffect(() => () => {
if (resetTimer.current) clearTimeout(resetTimer.current);
}, []);

async function copy() {
try {
await navigator.clipboard.writeText(value);
} catch {
// No clipboard access - the field is selectable for exactly this case, so leave it be.
return;
}

setCopied(true);
if (resetTimer.current) clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(() => setCopied(false), 2000);
}

return (
<div className={clsx("flex items-center gap-2 w-full", className)}>
<input
readOnly
value={value}
onFocus={e => e.currentTarget.select()}
aria-label={label}
className="flex-1 min-w-0 h-10 bg-darkless border border-slate rounded-lg px-3 text-xs font-mono text-secondary"
/>

<button
type="button"
onClick={copy}
title={copied ? "Copied!" : "Copy"}
aria-label={copied ? "Copied" : `Copy ${label.toLowerCase()}`}
className={clsx(
"relative shrink-0 w-10 h-10 rounded-lg border border-slate bg-dark cursor-pointer",
"transition-[background-color,transform] duration-150 hover:bg-darkless active:scale-95"
)}
>
{/*
The two glyphs are stacked and cross-faded rather than swapped: the tick grows out of the copy icon as
that shrinks away, instead of the button blinking through an empty frame. Sharing one absolutely
positioned box also centres them properly - the glyphs carry uneven padding inside their own viewBox,
which is what knocks them off-centre when they're laid out as flex children.
*/}
<span
aria-hidden
className={clsx(
"absolute inset-0 flex items-center justify-center text-white",
"transition-all duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]",
copied ? "scale-50 opacity-0 blur-[2px]" : "scale-100 opacity-100 blur-0"
)}
>
<Icon glyph="copy" size={20} />
</span>

<span
aria-hidden
className={clsx(
"absolute inset-0 flex items-center justify-center text-white",
"transition-all duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]",
copied ? "scale-100 opacity-100 blur-0" : "scale-50 opacity-0 blur-[2px]"
)}
>
{/*
The checkmark's artwork sits off-centre inside its own 32x32 viewBox - its ink spans x 10.4-28.6,
y 6.7-20.6, so its middle lands at (19.5, 13.6) instead of (16, 16). Flex centering aligns the box,
not the mark inside it, so the tick reads high and to the right without this. The correction is a
percentage of the icon's own size (-3.5/32 across, +2.4/32 down) rather than a pixel count, so it
still holds if the glyph is ever rendered at another size.
*/}
<Icon glyph="checkmark" size={20} className="translate-x-[-10.9%] translate-y-[7.4%]" />
</span>
</button>
</div>
);
}
6 changes: 5 additions & 1 deletion apps/client/src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ const App: AppType = ({ Component, pageProps }) => {
}, [router]);

useEffect(() => {
if (router.pathname.startsWith("/timelapse/publish") || router.pathname.startsWith("/timelapse/create"))
// `/timelapse/handoff` is exempt for the same reason as the other two: it's already taking the user to
// publish, and it has an unauthenticated state to show first that this redirect would trample.
if (router.pathname.startsWith("/timelapse/publish")
|| router.pathname.startsWith("/timelapse/create")
|| router.pathname.startsWith("/timelapse/handoff"))
return;

// Never probe a protected endpoint on the auth pages or without a session. The request would 401 and
Expand Down
Loading