diff --git a/packages/web/src/lib/jazz/schema.ts b/packages/web/src/lib/jazz/schema.ts index c31ad742..fd7cc114 100644 --- a/packages/web/src/lib/jazz/schema.ts +++ b/packages/web/src/lib/jazz/schema.ts @@ -183,6 +183,40 @@ export const StreamFilterConfig = co.map({ }) export type StreamFilterConfig = co.loaded +/** + * Individual browser tab within a session + */ +export const BrowserTab = z.object({ + title: z.string(), + url: z.string(), + favicon: z.string().nullable(), +}) +export type BrowserTab = z.infer + +/** + * A saved browser session - collection of tabs at a point in time + */ +export const BrowserSession = co.map({ + /** User-provided name for the session */ + name: z.string(), + /** Optional description */ + description: z.string().nullable(), + /** All tabs in this session */ + tabs: z.array(BrowserTab), + /** Browser type */ + browserType: z.enum(["safari", "chrome", "firefox", "arc", "other"]), + /** When session was saved */ + createdAt: z.number(), + /** Tags for organization */ + tags: z.array(z.string()), +}) +export type BrowserSession = co.loaded + +/** + * List of browser sessions + */ +export const BrowserSessionList = co.list(BrowserSession) + /** * Viewer account root - stores any viewer-specific data */ @@ -199,6 +233,8 @@ export const ViewerRoot = co.map({ cloudflareConfig: co.optional(CloudflareStreamConfig), /** Stream filter configuration (allowed/blocked apps) */ streamFilter: co.optional(StreamFilterConfig), + /** Saved browser sessions */ + browserSessions: BrowserSessionList, }) /** @@ -221,6 +257,7 @@ export const ViewerAccount = co savedUrls: SavedUrlList.create([]), glideCanvas: GlideCanvasList.create([]), streamRecordings: StreamRecordingList.create([]), + browserSessions: BrowserSessionList.create([]), }) } }) diff --git a/packages/web/src/routes/$username.tsx b/packages/web/src/routes/$username.tsx index 23e5636c..3af5cfa3 100644 --- a/packages/web/src/routes/$username.tsx +++ b/packages/web/src/routes/$username.tsx @@ -15,6 +15,9 @@ export const Route = createFileRoute("/$username")({ component: StreamPage, }) +// Feature flag: enable paywall for premium content +const PAYWALL_ENABLED = false + const READY_PULSE_MS = 1200 function StreamPage() { @@ -28,7 +31,9 @@ function StreamPage() { const [hlsUrl, setHlsUrl] = useState(null) const [isConnecting, setIsConnecting] = useState(false) const [showReadyPulse, setShowReadyPulse] = useState(false) - const readyPulseTimeoutRef = useRef | null>(null) + const readyPulseTimeoutRef = useRef | null>( + null, + ) const hasConnectedOnce = useRef(false) // Mobile overlays @@ -105,7 +110,7 @@ function StreamPage() { const stream = data?.stream ?? null const activePlayback = hlsUrl ? { type: "hls" as const, url: hlsUrl } - : stream?.playback ?? null + : (stream?.playback ?? null) // Poll HLS status via server-side API (avoids CORS issues) useEffect(() => { @@ -115,7 +120,9 @@ function StreamPage() { const checkHls = async () => { try { - const res = await fetch(`/api/streams/${username}/check-hls`, { cache: "no-store" }) + const res = await fetch(`/api/streams/${username}/check-hls`, { + cache: "no-store", + }) if (!isActive) return const apiData = await res.json() @@ -268,7 +275,9 @@ function StreamPage() {
-

Checking stream status...

+

+ Checking stream status... +

) : isActuallyLive && activePlayback ? (
@@ -282,7 +291,9 @@ function StreamPage() {
-

Connecting to stream...

+

+ Connecting to stream... +

)} {showReadyPulse && ( @@ -305,7 +316,11 @@ function StreamPage() {

{profileUser.website && (
-

Past Streams

-

Watch previous recordings

+

+ Past Streams +

+

+ Watch previous recordings +

{replaysLoading ? (
- ) : showPaywall ? ( + ) : PAYWALL_ENABLED && showPaywall ? ( - +
@@ -415,10 +431,7 @@ function StreamPage() {
- +
)} diff --git a/packages/web/src/routes/index.tsx b/packages/web/src/routes/index.tsx index 03e64b84..502c0f54 100644 --- a/packages/web/src/routes/index.tsx +++ b/packages/web/src/routes/index.tsx @@ -6,7 +6,7 @@ import { authClient } from "@/lib/auth-client" import { useAccount } from "jazz-tools/react" import { ViewerAccount, type SavedUrl } from "@/lib/jazz/schema" import { JazzProvider } from "@/lib/jazz/provider" -import { Link2, Plus, Trash2, ExternalLink, Video, Settings, LogOut } from "lucide-react" +import { Link2, Plus, Trash2, ExternalLink, Video, Settings, LogOut, Layers } from "lucide-react" // Feature flag: only this email can access stream features const STREAM_ENABLED_EMAIL = "nikita@nikiv.dev" @@ -154,6 +154,23 @@ function Dashboard() { )} + {/* Browser Sessions */} +
+
+ +

Browser Sessions

+
+

+ Save your browser tabs to access them anywhere. Synced across all devices. +

+ + Open Sessions + +
+ {/* Saved Links */}
diff --git a/packages/web/src/routes/sessions.tsx b/packages/web/src/routes/sessions.tsx index e100495a..f38ec4e2 100644 --- a/packages/web/src/routes/sessions.tsx +++ b/packages/web/src/routes/sessions.tsx @@ -1,388 +1,562 @@ -import { useState, useEffect, useCallback } from "react" -import { createFileRoute, Link } from "@tanstack/react-router" +import { useState, type FormEvent } from "react" +import { createFileRoute } from "@tanstack/react-router" import { authClient } from "@/lib/auth-client" +import { useAccount } from "jazz-tools/react" import { - Search, - Star, + ViewerAccount, + BrowserSession, + BrowserSessionList, + type BrowserTab, +} from "@/lib/jazz/schema" +import { + Layers, + Plus, + Trash2, ExternalLink, ChevronDown, ChevronRight, - Trash2, + Upload, Clock, - Globe, + Tag, + Search, } from "lucide-react" -import type { BrowserSession, BrowserSessionTab } from "@/db/schema" +import { JazzProvider } from "@/lib/jazz/provider" export const Route = createFileRoute("/sessions")({ - component: BrowserSessionsPage, + component: SessionsPageWrapper, ssr: false, }) -interface SessionWithTabs extends BrowserSession { - tabs?: BrowserSessionTab[] -} - -interface PaginationInfo { - page: number - limit: number - total: number - totalPages: number -} - -function getDomain(url: string): string { - try { - return new URL(url).hostname.replace("www.", "") - } catch { - return url - } -} - -function formatDate(date: Date | string): string { - const d = typeof date === "string" ? new Date(date) : date - return d.toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }) -} - -function SessionCard({ - session, - onToggleFavorite, - onDelete, -}: { - session: SessionWithTabs - onToggleFavorite: (id: string, isFavorite: boolean) => void - onDelete: (id: string) => void -}) { - const [expanded, setExpanded] = useState(false) - const [tabs, setTabs] = useState([]) - const [loading, setLoading] = useState(false) - - const loadTabs = useCallback(async () => { - if (tabs.length > 0) return - - setLoading(true) - try { - const res = await fetch(`/api/browser-sessions/${session.id}`) - if (res.ok) { - const data = await res.json() - setTabs(data.tabs || []) - } - } catch (error) { - console.error("Failed to load tabs:", error) - } finally { - setLoading(false) - } - }, [session.id, tabs.length]) - - const handleExpand = () => { - if (!expanded) { - loadTabs() - } - setExpanded(!expanded) - } - +function SessionsPageWrapper() { return ( -
-
- - -
-

- {session.name} -

-
- - {formatDate(session.captured_at)} - | - - {session.browser} - | - {session.tab_count} tabs -
-
- - - - -
- - {expanded && ( -
- )} -
+ + + ) } -function BrowserSessionsPage() { +function SessionsPage() { const { data: session, isPending: authPending } = authClient.useSession() - const [sessions, setSessions] = useState([]) - const [pagination, setPagination] = useState({ - page: 1, - limit: 50, - total: 0, - totalPages: 0, - }) - const [search, setSearch] = useState("") - const [loading, setLoading] = useState(true) + const me = useAccount(ViewerAccount) - const fetchSessions = useCallback( - async (page = 1, searchQuery = "") => { - setLoading(true) - try { - const body = { - action: "list" as const, - page, - limit: pagination.limit, - search: searchQuery || undefined, - } + const [isAdding, setIsAdding] = useState(false) + const [isImporting, setIsImporting] = useState(false) + const [expandedSessions, setExpandedSessions] = useState>(new Set()) + const [searchQuery, setSearchQuery] = useState("") - const res = await fetch("/api/browser-sessions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }) + // New session form state + const [newName, setNewName] = useState("") + const [newDescription, setNewDescription] = useState("") + const [newBrowser, setNewBrowser] = useState<"safari" | "chrome" | "firefox" | "arc" | "other">("safari") + const [newTags, setNewTags] = useState("") + const [newTabs, setNewTabs] = useState([]) + const [tabUrl, setTabUrl] = useState("") + const [tabTitle, setTabTitle] = useState("") - if (res.ok) { - const data = await res.json() - setSessions(data.sessions || []) - setPagination(data.pagination) - } - } catch (error) { - console.error("Failed to fetch sessions:", error) - } finally { - setLoading(false) - } - }, - [pagination.limit], - ) - - useEffect(() => { - if (session?.user) { - fetchSessions(1, search) - } - }, [session?.user, fetchSessions, search]) - - const handleToggleFavorite = async (id: string, isFavorite: boolean) => { - try { - const res = await fetch(`/api/browser-sessions/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ is_favorite: isFavorite }), - }) - - if (res.ok) { - setSessions((prev) => - prev.map((s) => (s.id === id ? { ...s, is_favorite: isFavorite } : s)), - ) - } - } catch (error) { - console.error("Failed to update favorite:", error) - } - } - - const handleDelete = async (id: string) => { - try { - const res = await fetch(`/api/browser-sessions/${id}`, { - method: "DELETE", - }) - - if (res.ok) { - setSessions((prev) => prev.filter((s) => s.id !== id)) - setPagination((prev) => ({ ...prev, total: prev.total - 1 })) - } - } catch (error) { - console.error("Failed to delete session:", error) - } - } + // Import state + const [importJson, setImportJson] = useState("") if (authPending) { return ( -
-

Loading...

+
+

Loading...

) } if (!session?.user) { return ( -
-
-

Sign in to view your browser sessions

- +
+

Please sign in to save browser sessions

+ Sign in - +
) } - // Group sessions by date - const sessionsByDate = sessions.reduce( - (acc, s) => { - const date = formatDate(s.captured_at) - if (!acc[date]) acc[date] = [] - acc[date].push(s) - return acc - }, - {} as Record, - ) + const root = me.$isLoaded ? me.root : null + + if (!me.$isLoaded || !root?.$isLoaded) { + return ( +
+

Loading Jazz...

+
+ ) + } + + // Initialize browserSessions if not present + if (!root.browserSessions) { + root.$jazz.set("browserSessions", BrowserSessionList.create([])) + } + + const sessionsList = root.browserSessions?.$isLoaded ? root.browserSessions : null + const allSessions = sessionsList?.$isLoaded ? [...sessionsList] : [] + + // Filter sessions by search query + const sessions = searchQuery + ? allSessions.filter((s) => { + const q = searchQuery.toLowerCase() + return ( + s.name?.toLowerCase().includes(q) || + s.description?.toLowerCase().includes(q) || + s.tags?.some((t) => t.toLowerCase().includes(q)) || + s.tabs?.some( + (tab) => + tab.title?.toLowerCase().includes(q) || tab.url?.toLowerCase().includes(q) + ) + ) + }) + : allSessions + + const toggleExpanded = (index: number) => { + const newExpanded = new Set(expandedSessions) + if (newExpanded.has(index)) { + newExpanded.delete(index) + } else { + newExpanded.add(index) + } + setExpandedSessions(newExpanded) + } + + const handleAddTab = () => { + if (!tabUrl.trim()) return + setNewTabs([ + ...newTabs, + { url: tabUrl.trim(), title: tabTitle.trim() || tabUrl.trim(), favicon: null }, + ]) + setTabUrl("") + setTabTitle("") + } + + const handleRemoveTab = (index: number) => { + setNewTabs(newTabs.filter((_, i) => i !== index)) + } + + const handleSaveSession = (e: FormEvent) => { + e.preventDefault() + if (!newName.trim() || newTabs.length === 0 || !root?.browserSessions?.$isLoaded) return + + const newSession = BrowserSession.create({ + name: newName.trim(), + description: newDescription.trim() || null, + tabs: newTabs, + browserType: newBrowser, + createdAt: Date.now(), + tags: newTags + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + }) + + root.browserSessions.$jazz.push(newSession) + + // Reset form + setNewName("") + setNewDescription("") + setNewBrowser("safari") + setNewTags("") + setNewTabs([]) + setIsAdding(false) + } + + const handleImport = (e: FormEvent) => { + e.preventDefault() + if (!importJson.trim() || !root?.browserSessions?.$isLoaded) return + + try { + const data = JSON.parse(importJson) + let tabs: BrowserTab[] = [] + + // Support both array format and object with tabs property + if (Array.isArray(data)) { + tabs = data.map((item: { title?: string; url: string }) => ({ + title: item.title || item.url, + url: item.url, + favicon: null, + })) + } else if (data.tabs && Array.isArray(data.tabs)) { + tabs = data.tabs.map((item: { title?: string; url: string }) => ({ + title: item.title || item.url, + url: item.url, + favicon: null, + })) + } + + if (tabs.length === 0) { + alert("No valid tabs found in JSON") + return + } + + const sessionName = data.name || `Imported ${new Date().toLocaleDateString()}` + + const newSession = BrowserSession.create({ + name: sessionName, + description: data.description || null, + tabs, + browserType: data.browserType || "other", + createdAt: Date.now(), + tags: data.tags || [], + }) + + root.browserSessions.$jazz.push(newSession) + + setImportJson("") + setIsImporting(false) + } catch { + alert("Invalid JSON format") + } + } + + const handleDeleteSession = (index: number) => { + if (!root?.browserSessions?.$isLoaded) return + root.browserSessions.$jazz.splice(index, 1) + } + + const formatDate = (timestamp: number) => { + return new Date(timestamp).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + } return ( -
-
-
-

Browser Sessions

-

- {pagination.total} sessions saved -

-
+
+
+
+
+ +

Browser Sessions

+ ({allSessions.length}) +
+
+ + +
+
{/* Search */}
- + setSearch(e.target.value)} - placeholder="Search sessions..." - className="w-full pl-10 pr-4 py-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-zinc-700 focus:ring-1 focus:ring-zinc-700" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + placeholder="Search sessions, tabs, or tags..." + className="w-full pl-10 pr-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-teal-500" />
- {/* Sessions list */} - {loading ? ( -
Loading sessions...
- ) : sessions.length === 0 ? ( -
- {search ? `No sessions found for "${search}"` : "No sessions yet"} -
- ) : ( -
- {Object.entries(sessionsByDate).map(([date, dateSessions]) => ( -
-

{date}

-
- {dateSessions.map((s) => ( - - ))} -
-
- ))} -
+ {/* Import Modal */} + {isImporting && ( +
+

Import Session from JSON

+

+ Paste JSON with format: {`[{title, url}, ...]`} or {`{name, tabs: [{title, url}, ...]}`} +

+