Add browser session schema and integrate into viewer data; update UI with sessions section and new session form.

This commit is contained in:
Nikita
2025-12-26 19:40:13 -08:00
parent 9a2e5c5a4a
commit 3a2c78198a
4 changed files with 596 additions and 355 deletions

View File

@@ -183,6 +183,40 @@ export const StreamFilterConfig = co.map({
})
export type StreamFilterConfig = co.loaded<typeof StreamFilterConfig>
/**
* 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<typeof BrowserTab>
/**
* 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<typeof BrowserSession>
/**
* 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([]),
})
}
})

View File

@@ -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<string | null>(null)
const [isConnecting, setIsConnecting] = useState(false)
const [showReadyPulse, setShowReadyPulse] = useState(false)
const readyPulseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const readyPulseTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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() {
<div className="relative">
<div className="w-16 h-16 border-4 border-white/20 border-t-white rounded-full animate-spin" />
</div>
<p className="mt-6 text-lg text-neutral-400">Checking stream status...</p>
<p className="mt-6 text-lg text-neutral-400">
Checking stream status...
</p>
</div>
) : isActuallyLive && activePlayback ? (
<div className="relative h-full w-full">
@@ -282,7 +291,9 @@ function StreamPage() {
<div className="relative">
<div className="w-16 h-16 border-4 border-white/20 border-t-red-500 rounded-full animate-spin" />
</div>
<p className="mt-6 text-lg text-white">Connecting to stream...</p>
<p className="mt-6 text-lg text-white">
Connecting to stream...
</p>
</div>
)}
{showReadyPulse && (
@@ -305,7 +316,11 @@ function StreamPage() {
</p>
{profileUser.website && (
<a
href={profileUser.website.startsWith("http") ? profileUser.website : `https://${profileUser.website}`}
href={
profileUser.website.startsWith("http")
? profileUser.website
: `https://${profileUser.website}`
}
target="_blank"
rel="noopener noreferrer"
className="mt-8 text-2xl md:text-3xl font-medium text-white hover:text-neutral-300 transition-colors"
@@ -320,15 +335,19 @@ function StreamPage() {
<div className="flex-shrink-0 border-t border-white/10">
<div className="max-w-7xl mx-auto">
<div className="px-6 py-6 border-b border-white/10">
<h2 className="text-xl font-bold text-white">Past Streams</h2>
<p className="text-sm text-white/60 mt-1">Watch previous recordings</p>
<h2 className="text-xl font-bold text-white">
Past Streams
</h2>
<p className="text-sm text-white/60 mt-1">
Watch previous recordings
</p>
</div>
{replaysLoading ? (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-4 border-white/20 border-t-white rounded-full animate-spin" />
</div>
) : showPaywall ? (
) : PAYWALL_ENABLED && showPaywall ? (
<PaywallBanner
creatorName={profileUser.name || profileUser.username}
creatorUsername={profileUser.username}
@@ -345,10 +364,7 @@ function StreamPage() {
{/* Desktop Profile Sidebar with Chat */}
<div className="hidden md:flex w-96 h-full flex-shrink-0">
<ProfileSidebar
user={profileUser}
isLive={isActuallyLive}
>
<ProfileSidebar user={profileUser} isLive={isActuallyLive}>
<CommentBox username={username} />
</ProfileSidebar>
</div>
@@ -415,10 +431,7 @@ function StreamPage() {
</button>
</div>
<div className="flex-1 min-h-0 overflow-auto">
<ProfileSidebar
user={profileUser}
isLive={isActuallyLive}
/>
<ProfileSidebar user={profileUser} isLive={isActuallyLive} />
</div>
</div>
)}

View File

@@ -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() {
</div>
)}
{/* Browser Sessions */}
<div className="mb-8 p-6 bg-gradient-to-r from-teal-500/10 to-cyan-500/10 border border-teal-500/20 rounded-2xl">
<div className="flex items-center gap-3 mb-4">
<Layers className="w-6 h-6 text-teal-400" />
<h2 className="text-xl font-semibold">Browser Sessions</h2>
</div>
<p className="text-neutral-400 mb-4">
Save your browser tabs to access them anywhere. Synced across all devices.
</p>
<Link
to="/sessions"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-teal-600 hover:bg-teal-500 transition-colors"
>
Open Sessions
</Link>
</div>
{/* Saved Links */}
<div className="bg-neutral-900/50 border border-white/5 rounded-2xl p-6">
<div className="flex items-center justify-between mb-6">

View File

@@ -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<BrowserSessionTab[]>([])
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 (
<div className="bg-zinc-900/50 border border-zinc-800 rounded-xl overflow-hidden">
<div
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-zinc-800/50 transition-colors"
onClick={handleExpand}
>
<button
type="button"
className="p-1 text-zinc-500 hover:text-zinc-300"
onClick={(e) => {
e.stopPropagation()
handleExpand()
}}
>
{expanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-zinc-200 truncate">
{session.name}
</p>
<div className="flex items-center gap-2 text-xs text-zinc-500 mt-0.5">
<Clock className="w-3 h-3" />
<span>{formatDate(session.captured_at)}</span>
<span className="text-zinc-600">|</span>
<Globe className="w-3 h-3" />
<span>{session.browser}</span>
<span className="text-zinc-600">|</span>
<span>{session.tab_count} tabs</span>
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleFavorite(session.id, !session.is_favorite)
}}
className={`p-1.5 rounded-lg transition-colors ${
session.is_favorite
? "text-yellow-400 hover:text-yellow-300"
: "text-zinc-600 hover:text-zinc-400"
}`}
title={session.is_favorite ? "Remove from favorites" : "Add to favorites"}
>
<Star className="w-4 h-4" fill={session.is_favorite ? "currentColor" : "none"} />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
if (confirm("Delete this session?")) {
onDelete(session.id)
}
}}
className="p-1.5 rounded-lg text-zinc-600 hover:text-red-400 transition-colors"
title="Delete session"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{expanded && (
<div className="border-t border-zinc-800 px-4 py-3">
{loading ? (
<p className="text-sm text-zinc-500">Loading tabs...</p>
) : tabs.length === 0 ? (
<p className="text-sm text-zinc-500">No tabs found</p>
) : (
<ul className="space-y-1">
{tabs.map((tab, idx) => (
<li key={tab.id} className="flex items-start gap-2">
<span className="text-xs text-zinc-600 font-mono w-5 text-right shrink-0 pt-1">
{idx + 1}
</span>
<a
href={tab.url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 min-w-0 group py-1 px-2 -mx-2 rounded hover:bg-zinc-800/50 transition-colors"
>
<p className="text-sm text-zinc-300 truncate group-hover:text-white">
{tab.title || tab.url}
</p>
<p className="text-xs text-zinc-600 truncate">
{getDomain(tab.url)}
</p>
</a>
<a
href={tab.url}
target="_blank"
rel="noopener noreferrer"
className="p-1 text-zinc-600 hover:text-zinc-400 shrink-0"
>
<ExternalLink className="w-3 h-3" />
</a>
</li>
))}
</ul>
)}
</div>
)}
</div>
<JazzProvider>
<SessionsPage />
</JazzProvider>
)
}
function BrowserSessionsPage() {
function SessionsPage() {
const { data: session, isPending: authPending } = authClient.useSession()
const [sessions, setSessions] = useState<SessionWithTabs[]>([])
const [pagination, setPagination] = useState<PaginationInfo>({
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<Set<number>>(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<BrowserTab[]>([])
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 (
<div className="min-h-screen bg-black text-white grid place-items-center">
<p className="text-zinc-500">Loading...</p>
<div className="min-h-screen text-white grid place-items-center">
<p className="text-slate-400">Loading...</p>
</div>
)
}
if (!session?.user) {
return (
<div className="min-h-screen bg-black text-white grid place-items-center">
<div className="text-center">
<p className="text-zinc-400 mb-4">Sign in to view your browser sessions</p>
<Link
to="/login"
className="inline-block px-4 py-2 bg-white text-black rounded-lg font-medium hover:bg-zinc-200 transition-colors"
<div className="min-h-screen text-white grid place-items-center">
<div className="text-center space-y-4">
<p className="text-slate-400">Please sign in to save browser sessions</p>
<a
href="/auth"
className="inline-block px-4 py-2 rounded-lg text-sm font-semibold text-white bg-teal-600 hover:bg-teal-500 transition-colors"
>
Sign in
</Link>
</a>
</div>
</div>
)
}
// 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<string, SessionWithTabs[]>,
)
const root = me.$isLoaded ? me.root : null
if (!me.$isLoaded || !root?.$isLoaded) {
return (
<div className="min-h-screen text-white grid place-items-center">
<p className="text-slate-400">Loading Jazz...</p>
</div>
)
}
// 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 (
<div className="min-h-screen bg-black text-white">
<div className="max-w-4xl mx-auto px-4 py-8">
<header className="mb-8">
<h1 className="text-2xl font-bold mb-2">Browser Sessions</h1>
<p className="text-zinc-500">
{pagination.total} sessions saved
</p>
</header>
<div className="min-h-screen text-white">
<div className="max-w-3xl mx-auto px-4 py-10">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<Layers className="w-6 h-6 text-teal-400" />
<h1 className="text-2xl font-semibold">Browser Sessions</h1>
<span className="text-sm text-white/50">({allSessions.length})</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsImporting(true)}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-white/5 hover:bg-white/10 border border-white/10 transition-colors"
>
<Upload className="w-4 h-4" />
Import
</button>
<button
type="button"
onClick={() => setIsAdding(true)}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-teal-600 hover:bg-teal-500 transition-colors"
>
<Plus className="w-4 h-4" />
New Session
</button>
</div>
</div>
{/* Search */}
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
<input
type="text"
value={search}
onChange={(e) => 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"
/>
</div>
{/* Sessions list */}
{loading ? (
<div className="text-center py-12 text-zinc-500">Loading sessions...</div>
) : sessions.length === 0 ? (
<div className="text-center py-12 text-zinc-500">
{search ? `No sessions found for "${search}"` : "No sessions yet"}
</div>
) : (
<div className="space-y-6">
{Object.entries(sessionsByDate).map(([date, dateSessions]) => (
<section key={date}>
<h2 className="text-sm font-medium text-zinc-400 mb-3">{date}</h2>
<div className="space-y-2">
{dateSessions.map((s) => (
<SessionCard
key={s.id}
session={s}
onToggleFavorite={handleToggleFavorite}
onDelete={handleDelete}
/>
))}
</div>
</section>
))}
</div>
{/* Import Modal */}
{isImporting && (
<form
onSubmit={handleImport}
className="mb-6 p-4 bg-[#0c0f18] border border-white/10 rounded-xl space-y-4"
>
<h3 className="text-lg font-medium">Import Session from JSON</h3>
<p className="text-sm text-white/60">
Paste JSON with format: {`[{title, url}, ...]`} or {`{name, tabs: [{title, url}, ...]}`}
</p>
<textarea
value={importJson}
onChange={(e) => setImportJson(e.target.value)}
placeholder='[{"title": "Example", "url": "https://example.com"}]'
rows={6}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500 font-mono text-sm"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsImporting(false)
setImportJson("")
}}
className="px-4 py-2 rounded-lg text-sm text-slate-200 bg-white/5 hover:bg-white/10 border border-white/10 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 rounded-lg text-sm font-semibold text-white bg-teal-600 hover:bg-teal-500 transition-colors"
>
Import
</button>
</div>
</form>
)}
{/* Pagination */}
{pagination.totalPages > 1 && (
<nav className="flex items-center justify-center gap-2 mt-8">
<button
type="button"
onClick={() => fetchSessions(pagination.page - 1, search)}
disabled={pagination.page <= 1}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-sm transition-colors"
>
Previous
</button>
<span className="px-3 text-sm text-zinc-500">
Page {pagination.page} of {pagination.totalPages}
</span>
<button
type="button"
onClick={() => fetchSessions(pagination.page + 1, search)}
disabled={pagination.page >= pagination.totalPages}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-sm transition-colors"
>
Next
</button>
</nav>
{/* Add Session Form */}
{isAdding && (
<form
onSubmit={handleSaveSession}
className="mb-6 p-4 bg-[#0c0f18] border border-white/10 rounded-xl space-y-4"
>
<h3 className="text-lg font-medium">New Browser Session</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm text-white/70">Session Name</label>
<input
type="text"
required
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Research tabs"
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm text-white/70">Browser</label>
<select
value={newBrowser}
onChange={(e) =>
setNewBrowser(e.target.value as "safari" | "chrome" | "firefox" | "arc" | "other")
}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-teal-500"
>
<option value="safari">Safari</option>
<option value="chrome">Chrome</option>
<option value="firefox">Firefox</option>
<option value="arc">Arc</option>
<option value="other">Other</option>
</select>
</div>
</div>
<div className="space-y-2">
<label className="text-sm text-white/70">Description (optional)</label>
<input
type="text"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder="Tabs for my research project"
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm text-white/70">Tags (comma-separated)</label>
<input
type="text"
value={newTags}
onChange={(e) => setNewTags(e.target.value)}
placeholder="work, research, important"
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
</div>
{/* Add Tab */}
<div className="space-y-2">
<label className="text-sm text-white/70">Add Tabs</label>
<div className="flex gap-2">
<input
type="url"
value={tabUrl}
onChange={(e) => setTabUrl(e.target.value)}
placeholder="https://example.com"
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
<input
type="text"
value={tabTitle}
onChange={(e) => setTabTitle(e.target.value)}
placeholder="Title (optional)"
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
<button
type="button"
onClick={handleAddTab}
className="px-3 py-2 rounded-lg text-sm font-medium text-white bg-white/10 hover:bg-white/20 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
{/* Tabs List */}
{newTabs.length > 0 && (
<div className="space-y-2">
<p className="text-sm text-white/70">
{newTabs.length} tab{newTabs.length !== 1 ? "s" : ""}
</p>
<div className="max-h-40 overflow-auto space-y-1">
{newTabs.map((tab, i) => (
<div key={i} className="flex items-center gap-2 p-2 bg-white/5 rounded-lg">
<span className="flex-1 text-sm truncate">{tab.title}</span>
<span className="text-xs text-white/50 truncate max-w-[200px]">{tab.url}</span>
<button
type="button"
onClick={() => handleRemoveTab(i)}
className="p-1 text-rose-400 hover:text-rose-300"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsAdding(false)
setNewName("")
setNewDescription("")
setNewTags("")
setNewTabs([])
}}
className="px-4 py-2 rounded-lg text-sm text-slate-200 bg-white/5 hover:bg-white/10 border border-white/10 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={newTabs.length === 0}
className="px-4 py-2 rounded-lg text-sm font-semibold text-white bg-teal-600 hover:bg-teal-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Save Session
</button>
</div>
</form>
)}
{/* Sessions List */}
{sessions.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<Layers className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>{searchQuery ? `No sessions found for "${searchQuery}"` : "No saved sessions yet"}</p>
<p className="text-sm mt-1">
{searchQuery ? "Try a different search term" : "Save your browser tabs to access them anywhere"}
</p>
</div>
) : (
<div className="space-y-3">
{sessions
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
.map((item, index) => {
const isExpanded = expandedSessions.has(index)
const tabCount = item.tabs?.length || 0
return (
<div
key={index}
className="bg-[#0c0f18] border border-white/5 rounded-xl overflow-hidden"
>
{/* Session Header */}
<div
className="flex items-center gap-3 p-4 cursor-pointer hover:bg-white/5 transition-colors"
onClick={() => toggleExpanded(index)}
>
<button type="button" className="text-white/50">
{isExpanded ? (
<ChevronDown className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-white truncate">{item.name}</p>
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10 text-white/60">
{item.browserType}
</span>
<span className="text-xs text-white/50">
{tabCount} tab{tabCount !== 1 ? "s" : ""}
</span>
</div>
{item.description && (
<p className="text-sm text-white/50 truncate mt-1">{item.description}</p>
)}
<div className="flex items-center gap-3 mt-2 text-xs text-white/40">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDate(item.createdAt)}
</span>
{item.tags && item.tags.length > 0 && (
<span className="flex items-center gap-1">
<Tag className="w-3 h-3" />
{item.tags.join(", ")}
</span>
)}
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleDeleteSession(index)
}}
className="p-2 rounded-lg text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Expanded Tabs */}
{isExpanded && item.tabs && (
<div className="border-t border-white/5 p-4 space-y-2">
{item.tabs.map((tab, tabIndex) => (
<div
key={tabIndex}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 transition-colors group"
>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{tab.title}</p>
<p className="text-xs text-white/40 truncate">{tab.url}</p>
</div>
<a
href={tab.url}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors opacity-0 group-hover:opacity-100"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
))}
<div className="pt-2 border-t border-white/5">
<button
type="button"
onClick={() => {
item.tabs?.forEach((tab) => {
window.open(tab.url, "_blank")
})
}}
className="text-sm text-teal-400 hover:text-teal-300 transition-colors"
>
Open all {tabCount} tabs
</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>