diff --git a/mobile/src/app/(tabs)/capture.tsx b/mobile/src/app/(tabs)/capture.tsx index f5299a0..5bfd6f6 100644 --- a/mobile/src/app/(tabs)/capture.tsx +++ b/mobile/src/app/(tabs)/capture.tsx @@ -27,6 +27,7 @@ export default function CaptureScreen() { const [saved, setSaved] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [intakeResult, setIntakeResult] = useState(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' && ( <> - - - {busy ? 'Extracting...' : 'Extract & Summarize'} - + { setUrlInput(t); setIntakeResult(null); }} placeholder="https://example.com/article" placeholderTextColor={colors.textTertiary} style={styles.input} autoCapitalize="none" keyboardType="url" /> + {intakeResult ? ( + + Processing as {intakeResult.contentType}… + Job: {intakeResult.jobId} + + ) : ( + void handleUrlIntake()} disabled={busy || !urlInput.trim()} style={[styles.button, busy ? styles.buttonDisabled : null]}> + {busy ? 'Submitting...' : 'Process with AI'} + + )} {body ? ( <> - + ) : null} diff --git a/mobile/src/lib/share-intent.ts b/mobile/src/lib/share-intent.ts new file mode 100644 index 0000000..462760c --- /dev/null +++ b/mobile/src/lib/share-intent.ts @@ -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; +} diff --git a/web/src/app/(app)/dashboard/page.tsx b/web/src/app/(app)/dashboard/page.tsx index 85a8b63..f246c1f 100644 --- a/web/src/app/(app)/dashboard/page.tsx +++ b/web/src/app/(app)/dashboard/page.tsx @@ -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() { } > + router.push(`/notes/${noteId}`)} + /> +
{summaryCards.map((card) => ( diff --git a/web/src/components/IntakeUrlBar.tsx b/web/src/components/IntakeUrlBar.tsx new file mode 100644 index 0000000..718b4b5 --- /dev/null +++ b/web/src/components/IntakeUrlBar.tsx @@ -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 = { + youtube: "YouTube", + article: "Article", + pdf: "PDF", + tweet: "Tweet", + reddit: "Reddit", + github: "GitHub", + generic: "Link", +}; + +const CONTENT_TYPE_COLORS: Record = { + 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 ( +
+
+ 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 && ( + + {CONTENT_TYPE_LABELS[detectedType]} + + )} +
+ +
+ ); +} diff --git a/web/src/components/ShareDialog.tsx b/web/src/components/ShareDialog.tsx new file mode 100644 index 0000000..755c651 --- /dev/null +++ b/web/src/components/ShareDialog.tsx @@ -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 ( +
{ if (e.target === e.currentTarget) onClose(); }} + role="dialog" + aria-label="Share note" + > +
+
+

Share Note

+ +
+ +
+ {tabs.map((t) => ( + + ))} +
+ + {tab === "link" && ( +
+

+ Generate a public read-only link anyone can view. +

+ +
+ )} + + {tab === "user" && ( +
+

+ Share directly with a NoteLett user by their ID. +

+ 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)", + }} + /> + + +
+ )} + + {tab === "text" && ( +
+

+ Copy the note content as plain text — paste into email, WhatsApp, Messages, etc. +

+ +
+ )} + + {tab === "native" && ( +
+

+ Open your device's native share sheet (AirDrop, Messages, email, etc.) +

+ + {typeof navigator !== "undefined" && !navigator.share && ( +

+ Web Share API not supported in this browser. +

+ )} +
+ )} +
+
+ ); +} diff --git a/web/src/lib/intake-client.ts b/web/src/lib/intake-client.ts new file mode 100644 index 0000000..1671d22 --- /dev/null +++ b/web/src/lib/intake-client.ts @@ -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 { + const api = createNotesApiClient(); + const payload: Record = { 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 { + 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, +): Promise { + const api = createNotesApiClient(); + return api.fetch("/intake-rules", { + method: "POST", + body: JSON.stringify(rule), + }); +} + +export async function deleteIntakeRule(id: string): Promise { + 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 { + 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 { + 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)}`, + ); +}