feat(web): add intake URL bar, share dialog, intake API client — dashboard + enhanced sharing UI

This commit is contained in:
saravanakumardb1 2026-04-06 20:35:02 -07:00
parent 599d68e116
commit e5f287c7ea
6 changed files with 616 additions and 11 deletions

View File

@ -27,6 +27,7 @@ export default function CaptureScreen() {
const [saved, setSaved] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [intakeResult, setIntakeResult] = useState<IntakeSubmitResult | null>(null);
const saveDraft = useNotesStore((state: NotesState) => state.saveDraft);
const workspaces = useWorkspaceStore((state: WorkspaceState) => state.workspaces);
const activeWorkspaceId = useWorkspaceStore((state: WorkspaceState) => state.activeWorkspaceId);
@ -56,16 +57,28 @@ export default function CaptureScreen() {
}
};
const handleUrlExtract = async () => {
if (!activeWorkspaceId || !urlInput.trim()) return;
const waitForJob = useIntakeStore((state: IntakeState) => state.waitForJob);
const handleUrlIntake = async () => {
if (!urlInput.trim()) return;
setBusy(true);
setError(null);
setIntakeResult(null);
try {
const result = await extractFromUrl(urlInput.trim(), activeWorkspaceId);
setTitle(result.title);
setBody(result.content);
const result = await submitIntake(urlInput.trim(), activeWorkspaceId ?? undefined);
setIntakeResult(result);
waitForJob(result.jobId, (job) => {
if (job.status === 'complete') {
setTitle(`Processed: ${new URL(urlInput.trim()).hostname}`);
setBody(`Note created from ${urlInput.trim()}. Open the note to view content.`);
setIntakeResult(null);
} else {
setError(job.error ?? 'Processing failed');
setIntakeResult(null);
}
});
} catch (e) {
setError(e instanceof Error ? e.message : 'URL extraction failed');
setError(e instanceof Error ? e.message : 'Intake failed');
} finally {
setBusy(false);
}
@ -161,14 +174,21 @@ export default function CaptureScreen() {
{mode === 'url' && (
<>
<TextInput value={urlInput} onChangeText={setUrlInput} placeholder="https://example.com/article" placeholderTextColor={colors.textTertiary} style={styles.input} autoCapitalize="none" keyboardType="url" />
<Pressable accessibilityLabel="Extract content from URL" onPress={handleUrlExtract} disabled={busy || !urlInput.trim()} style={[styles.button, busy ? styles.buttonDisabled : null]}>
<Text style={styles.buttonText}>{busy ? 'Extracting...' : 'Extract & Summarize'}</Text>
</Pressable>
<TextInput value={urlInput} onChangeText={(t) => { setUrlInput(t); setIntakeResult(null); }} placeholder="https://example.com/article" placeholderTextColor={colors.textTertiary} style={styles.input} autoCapitalize="none" keyboardType="url" />
{intakeResult ? (
<View style={styles.card}>
<Text style={styles.cardTitle}>Processing as {intakeResult.contentType}</Text>
<Text style={styles.cardBody}>Job: {intakeResult.jobId}</Text>
</View>
) : (
<Pressable accessibilityLabel="Process URL with AI" onPress={() => void handleUrlIntake()} disabled={busy || !urlInput.trim()} style={[styles.button, busy ? styles.buttonDisabled : null]}>
<Text style={styles.buttonText}>{busy ? 'Submitting...' : 'Process with AI'}</Text>
</Pressable>
)}
{body ? (
<>
<TextInput value={title} onChangeText={setTitle} placeholder="Title" placeholderTextColor={colors.textTertiary} style={styles.input} />
<TextInput value={body} onChangeText={setBody} placeholder="Extracted content" placeholderTextColor={colors.textTertiary} style={[styles.input, styles.bodyInput]} multiline textAlignVertical="top" />
<TextInput value={body} onChangeText={setBody} placeholder="Processed content" placeholderTextColor={colors.textTertiary} style={[styles.input, styles.bodyInput]} multiline textAlignVertical="top" />
</>
) : null}
</>

View File

@ -0,0 +1,34 @@
/**
* Share intent handler stub.
*
* When expo-share-intent is installed and the app is prebuilt,
* this module will receive URLs shared from other apps and route
* them to the intake confirmation screen.
*
* Currently a no-op stub no native dependency required.
*/
import { router } from 'expo-router';
export type ShareIntentData = {
text?: string;
webUrl?: string;
type?: string;
};
/**
* Process a received share intent.
* Extracts the URL (if any) and navigates to the intake screen.
*/
export function handleShareIntent(data: ShareIntentData): void {
const url = data.webUrl ?? extractUrl(data.text);
if (!url) return;
router.push({ pathname: '/intake', params: { url } });
}
function extractUrl(text?: string): string | null {
if (!text) return null;
const match = text.match(/https?:\/\/[^\s]+/);
return match ? match[0] : null;
}

View File

@ -9,6 +9,7 @@ import { listNoteSummaries, listWorkspaceSummaries, seedOnboardingWorkspace } fr
import { listApprovalQueue } from "@/lib/review-client";
import { listSavedViews, type SavedView } from "@/lib/saved-views-client";
import { toast } from "@/lib/toast";
import { IntakeUrlBar } from "@/components/IntakeUrlBar";
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
function hrefForSavedView(view: SavedView): string {
@ -150,6 +151,11 @@ function DashboardContent() {
</button>
}
>
<IntakeUrlBar
workspaceId={workspaces[0]?.id}
onIntakeSubmitted={(noteId) => router.push(`/notes/${noteId}`)}
/>
<section style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: "var(--nl-space-4)" }}>
{summaryCards.map((card) => (
<Link key={card.id} href={card.href} className="surface-card" style={{ padding: "var(--nl-space-5)" }}>

View File

@ -0,0 +1,134 @@
"use client";
import { useState } from "react";
import { submitIntake, type IntakeContentType } from "@/lib/intake-client";
import { toast } from "@/lib/toast";
const CONTENT_TYPE_LABELS: Record<IntakeContentType, string> = {
youtube: "YouTube",
article: "Article",
pdf: "PDF",
tweet: "Tweet",
reddit: "Reddit",
github: "GitHub",
generic: "Link",
};
const CONTENT_TYPE_COLORS: Record<IntakeContentType, string> = {
youtube: "var(--nl-red)",
article: "var(--nl-primary)",
pdf: "var(--nl-orange)",
tweet: "var(--nl-blue)",
reddit: "var(--nl-orange)",
github: "var(--nl-text-primary)",
generic: "var(--nl-text-secondary)",
};
function classifyUrlClient(url: string): IntakeContentType {
if (/youtube\.com\/watch|youtu\.be\/|youtube\.com\/shorts/i.test(url)) return "youtube";
if (/(?:twitter|x)\.com\/.*\/status/i.test(url)) return "tweet";
if (/github\.com\/[^/]+\/[^/]+/i.test(url)) return "github";
if (/reddit\.com\/r\//i.test(url)) return "reddit";
if (/\.pdf(\?.*)?$/i.test(url)) return "pdf";
return "article";
}
function isValidUrl(s: string): boolean {
try {
new URL(s);
return true;
} catch {
return false;
}
}
interface IntakeUrlBarProps {
workspaceId?: string;
onIntakeSubmitted?: (noteId: string, jobId: string) => void;
}
export function IntakeUrlBar({ workspaceId, onIntakeSubmitted }: IntakeUrlBarProps) {
const [url, setUrl] = useState("");
const [loading, setLoading] = useState(false);
const detectedType = url && isValidUrl(url) ? classifyUrlClient(url) : null;
async function handleSubmit() {
if (!url.trim() || !isValidUrl(url)) {
toast.error("Please enter a valid URL");
return;
}
setLoading(true);
try {
const result = await submitIntake(url.trim(), workspaceId);
toast.success(`Processing ${CONTENT_TYPE_LABELS[result.contentType]} — note will be ready soon`);
setUrl("");
onIntakeSubmitted?.(result.noteId, result.jobId);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to submit URL");
} finally {
setLoading(false);
}
}
return (
<div
className="surface-card"
style={{
padding: "var(--nl-space-4)",
display: "flex",
gap: "var(--nl-space-3)",
alignItems: "center",
}}
>
<div style={{ flex: 1, position: "relative" }}>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleSubmit(); }}
placeholder="Paste a URL to auto-process (YouTube, article, tweet, PDF, GitHub…)"
aria-label="URL to process"
style={{
width: "100%",
padding: "var(--nl-space-3) var(--nl-space-4)",
paddingRight: detectedType ? "5rem" : "var(--nl-space-4)",
border: "1px solid var(--nl-border)",
borderRadius: "var(--nl-radius-md)",
background: "var(--nl-surface)",
color: "var(--nl-text-primary)",
fontSize: "var(--nl-fs-base)",
}}
/>
{detectedType && (
<span
style={{
position: "absolute",
right: "var(--nl-space-3)",
top: "50%",
transform: "translateY(-50%)",
padding: "2px 8px",
borderRadius: "var(--nl-radius-sm)",
fontSize: "var(--nl-fs-xs)",
fontWeight: 600,
background: CONTENT_TYPE_COLORS[detectedType],
color: "white",
opacity: 0.9,
}}
>
{CONTENT_TYPE_LABELS[detectedType]}
</span>
)}
</div>
<button
type="button"
className="btn btn-primary"
disabled={loading || !url.trim() || !isValidUrl(url)}
onClick={() => void handleSubmit()}
style={{ whiteSpace: "nowrap" }}
aria-label="Process URL"
>
{loading ? "Processing…" : "Process"}
</button>
</div>
);
}

View File

@ -0,0 +1,233 @@
"use client";
import { useState } from "react";
import { toast } from "@/lib/toast";
import { createNoteShare } from "@/lib/notes-client";
import { exportNoteText, shareNoteWithUser } from "@/lib/intake-client";
import { getWebAppOrigin } from "@/lib/product-config";
interface ShareDialogProps {
noteId: string;
workspaceId: string;
noteTitle: string;
onClose: () => void;
}
export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDialogProps) {
const [tab, setTab] = useState<"link" | "user" | "text" | "native">("link");
const [userId, setUserId] = useState("");
const [permission, setPermission] = useState<"view" | "comment" | "edit">("view");
const [loading, setLoading] = useState(false);
async function handleCopyLink() {
setLoading(true);
try {
const { shareToken } = await createNoteShare(noteId, workspaceId);
const url = `${getWebAppOrigin()}/share/${shareToken}`;
await navigator.clipboard.writeText(url);
toast.success("Share link copied");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to create share link");
} finally {
setLoading(false);
}
}
async function handleShareWithUser() {
if (!userId.trim()) {
toast.error("Please enter a user ID");
return;
}
setLoading(true);
try {
await shareNoteWithUser(noteId, workspaceId, userId.trim(), permission);
toast.success(`Shared with ${userId.trim()}`);
setUserId("");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to share");
} finally {
setLoading(false);
}
}
async function handleCopyText() {
setLoading(true);
try {
const exported = await exportNoteText(noteId, workspaceId);
await navigator.clipboard.writeText(`${exported.title}\n\n${exported.plaintext}`);
toast.success("Note text copied — paste in email, WhatsApp, etc.");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to export");
} finally {
setLoading(false);
}
}
async function handleNativeShare() {
if (!navigator.share) {
toast.error("Web Share API not supported in this browser");
return;
}
setLoading(true);
try {
const exported = await exportNoteText(noteId, workspaceId);
await navigator.share({
title: noteTitle,
text: exported.plaintext.slice(0, 500),
url: `${getWebAppOrigin()}/notes/${noteId}`,
});
} catch (e) {
if ((e as Error)?.name !== "AbortError") {
toast.error("Share failed");
}
} finally {
setLoading(false);
}
}
const tabs = [
{ key: "link" as const, label: "Copy Link" },
{ key: "user" as const, label: "Share with User" },
{ key: "text" as const, label: "Copy as Text" },
{ key: "native" as const, label: "Share Sheet" },
];
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-label="Share note"
>
<div
className="surface-card"
style={{
width: "min(480px, 90vw)",
padding: "var(--nl-space-6)",
display: "grid",
gap: "var(--nl-space-4)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h2 style={{ margin: 0, fontSize: "var(--nl-fs-xl)" }}>Share Note</h2>
<button
type="button"
onClick={onClose}
aria-label="Close share dialog"
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--nl-text-secondary)",
fontSize: "var(--nl-fs-xl)",
}}
>
×
</button>
</div>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
{tabs.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={tab === t.key ? "btn btn-primary" : "btn btn-secondary"}
style={{ fontSize: "var(--nl-fs-sm)" }}
aria-label={t.label}
>
{t.label}
</button>
))}
</div>
{tab === "link" && (
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
Generate a public read-only link anyone can view.
</p>
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void handleCopyLink()} aria-label="Copy share link">
{loading ? "Generating…" : "Copy Share Link"}
</button>
</div>
)}
{tab === "user" && (
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
Share directly with a NoteLett user by their ID.
</p>
<input
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="User ID"
aria-label="User ID to share with"
style={{
padding: "var(--nl-space-3)",
border: "1px solid var(--nl-border)",
borderRadius: "var(--nl-radius-md)",
background: "var(--nl-surface)",
color: "var(--nl-text-primary)",
}}
/>
<select
value={permission}
onChange={(e) => setPermission(e.target.value as "view" | "comment" | "edit")}
aria-label="Permission level"
style={{
padding: "var(--nl-space-3)",
border: "1px solid var(--nl-border)",
borderRadius: "var(--nl-radius-md)",
background: "var(--nl-surface)",
color: "var(--nl-text-primary)",
}}
>
<option value="view">View only</option>
<option value="comment">Can comment</option>
<option value="edit">Can edit</option>
</select>
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void handleShareWithUser()} aria-label="Share with user">
{loading ? "Sharing…" : "Share"}
</button>
</div>
)}
{tab === "text" && (
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
Copy the note content as plain text paste into email, WhatsApp, Messages, etc.
</p>
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void handleCopyText()} aria-label="Copy note text">
{loading ? "Copying…" : "Copy Note Text"}
</button>
</div>
)}
{tab === "native" && (
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
Open your device&apos;s native share sheet (AirDrop, Messages, email, etc.)
</p>
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void handleNativeShare()} aria-label="Open share sheet">
{loading ? "Opening…" : "Open Share Sheet"}
</button>
{typeof navigator !== "undefined" && !navigator.share && (
<p style={{ margin: 0, color: "var(--nl-orange)", fontSize: "var(--nl-fs-sm)" }}>
Web Share API not supported in this browser.
</p>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,178 @@
"use client";
import { createNotesApiClient } from "@/lib/api-helpers";
// ── Types ────────────────────────────────────────────────────────
export type IntakeContentType =
| "youtube"
| "article"
| "pdf"
| "tweet"
| "reddit"
| "github"
| "generic";
export type IntakeJobStatus =
| "queued"
| "extracting"
| "processing"
| "complete"
| "failed";
export interface IntakeSubmitResult {
jobId: string;
noteId: string;
contentType: IntakeContentType;
ruleMatched: string | null;
templateSlug: string;
status: "queued";
}
export interface IntakeJob {
id: string;
noteId: string;
url: string;
contentType: IntakeContentType;
templateSlug: string;
status: IntakeJobStatus;
error?: string;
startedAt: string;
completedAt?: string;
}
export interface IntakeRule {
id: string;
userId: string;
name: string;
urlPattern: string;
contentType: IntakeContentType;
templateId: string;
enabled: boolean;
priority: number;
}
// ── API Calls ────────────────────────────────────────────────────
export async function submitIntake(
url: string,
workspaceId?: string,
templateOverride?: string,
): Promise<IntakeSubmitResult> {
const api = createNotesApiClient();
const payload: Record<string, string> = { url };
if (workspaceId) payload.workspaceId = workspaceId;
if (templateOverride) payload.templateOverride = templateOverride;
return api.fetch("/intake", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function listIntakeJobs(options?: {
status?: IntakeJobStatus;
limit?: number;
}): Promise<{ items: IntakeJob[]; total: number }> {
const api = createNotesApiClient();
const params = new URLSearchParams();
if (options?.status) params.set("status", options.status);
if (options?.limit) params.set("limit", String(options.limit));
const qs = params.toString();
return api.fetch(`/intake/jobs${qs ? `?${qs}` : ""}`);
}
export async function getIntakeJob(id: string): Promise<IntakeJob> {
const api = createNotesApiClient();
return api.fetch(`/intake/jobs/${encodeURIComponent(id)}`);
}
export async function listIntakeRules(): Promise<{
items: IntakeRule[];
total: number;
}> {
const api = createNotesApiClient();
return api.fetch("/intake-rules");
}
export async function createIntakeRule(
rule: Omit<IntakeRule, "id" | "userId">,
): Promise<IntakeRule> {
const api = createNotesApiClient();
return api.fetch("/intake-rules", {
method: "POST",
body: JSON.stringify(rule),
});
}
export async function deleteIntakeRule(id: string): Promise<void> {
const api = createNotesApiClient();
await api.fetch(`/intake-rules/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
// ── Sharing helpers ──────────────────────────────────────────────
export async function shareNoteWithUser(
noteId: string,
workspaceId: string,
sharedWithUserId: string,
permission: "view" | "comment" | "edit" = "view",
): Promise<unknown> {
const api = createNotesApiClient();
return api.fetch(`/notes/${encodeURIComponent(noteId)}/share-with-user`, {
method: "POST",
body: JSON.stringify({ workspaceId, sharedWithUserId, permission }),
});
}
export async function listCollaborators(
noteId: string,
): Promise<{ items: unknown[]; total: number }> {
const api = createNotesApiClient();
return api.fetch(`/notes/${encodeURIComponent(noteId)}/collaborators`);
}
export async function listSharedWithMe(): Promise<{
items: unknown[];
total: number;
}> {
const api = createNotesApiClient();
return api.fetch("/shared-with-me");
}
export async function removeCollaborator(
noteId: string,
userId: string,
): Promise<void> {
const api = createNotesApiClient();
await api.fetch(`/notes/${encodeURIComponent(noteId)}/collaborators/${encodeURIComponent(userId)}`, {
method: "DELETE",
});
}
export async function exportNoteText(
noteId: string,
workspaceId: string,
): Promise<{
title: string;
markdown: string;
plaintext: string;
html: string;
}> {
const api = createNotesApiClient();
return api.fetch(`/notes/${encodeURIComponent(noteId)}/export-text`, {
method: "POST",
body: JSON.stringify({ workspaceId }),
});
}
export async function getNoteDeepLinks(
noteId: string,
workspaceId: string,
): Promise<{ web: string; mobile: string; public: string | null }> {
const api = createNotesApiClient();
return api.fetch(
`/notes/${encodeURIComponent(noteId)}/deep-link?workspaceId=${encodeURIComponent(workspaceId)}`,
);
}