mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-24 17:28:41 +02:00
Add browser session schema and integrate into viewer data; update UI with sessions section and new session form.
This commit is contained in:
@@ -183,6 +183,40 @@ export const StreamFilterConfig = co.map({
|
|||||||
})
|
})
|
||||||
export type StreamFilterConfig = co.loaded<typeof StreamFilterConfig>
|
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
|
* Viewer account root - stores any viewer-specific data
|
||||||
*/
|
*/
|
||||||
@@ -199,6 +233,8 @@ export const ViewerRoot = co.map({
|
|||||||
cloudflareConfig: co.optional(CloudflareStreamConfig),
|
cloudflareConfig: co.optional(CloudflareStreamConfig),
|
||||||
/** Stream filter configuration (allowed/blocked apps) */
|
/** Stream filter configuration (allowed/blocked apps) */
|
||||||
streamFilter: co.optional(StreamFilterConfig),
|
streamFilter: co.optional(StreamFilterConfig),
|
||||||
|
/** Saved browser sessions */
|
||||||
|
browserSessions: BrowserSessionList,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -221,6 +257,7 @@ export const ViewerAccount = co
|
|||||||
savedUrls: SavedUrlList.create([]),
|
savedUrls: SavedUrlList.create([]),
|
||||||
glideCanvas: GlideCanvasList.create([]),
|
glideCanvas: GlideCanvasList.create([]),
|
||||||
streamRecordings: StreamRecordingList.create([]),
|
streamRecordings: StreamRecordingList.create([]),
|
||||||
|
browserSessions: BrowserSessionList.create([]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export const Route = createFileRoute("/$username")({
|
|||||||
component: StreamPage,
|
component: StreamPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Feature flag: enable paywall for premium content
|
||||||
|
const PAYWALL_ENABLED = false
|
||||||
|
|
||||||
const READY_PULSE_MS = 1200
|
const READY_PULSE_MS = 1200
|
||||||
|
|
||||||
function StreamPage() {
|
function StreamPage() {
|
||||||
@@ -28,7 +31,9 @@ function StreamPage() {
|
|||||||
const [hlsUrl, setHlsUrl] = useState<string | null>(null)
|
const [hlsUrl, setHlsUrl] = useState<string | null>(null)
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
const [isConnecting, setIsConnecting] = useState(false)
|
||||||
const [showReadyPulse, setShowReadyPulse] = 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)
|
const hasConnectedOnce = useRef(false)
|
||||||
|
|
||||||
// Mobile overlays
|
// Mobile overlays
|
||||||
@@ -105,7 +110,7 @@ function StreamPage() {
|
|||||||
const stream = data?.stream ?? null
|
const stream = data?.stream ?? null
|
||||||
const activePlayback = hlsUrl
|
const activePlayback = hlsUrl
|
||||||
? { type: "hls" as const, url: hlsUrl }
|
? { type: "hls" as const, url: hlsUrl }
|
||||||
: stream?.playback ?? null
|
: (stream?.playback ?? null)
|
||||||
|
|
||||||
// Poll HLS status via server-side API (avoids CORS issues)
|
// Poll HLS status via server-side API (avoids CORS issues)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -115,7 +120,9 @@ function StreamPage() {
|
|||||||
|
|
||||||
const checkHls = async () => {
|
const checkHls = async () => {
|
||||||
try {
|
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
|
if (!isActive) return
|
||||||
|
|
||||||
const apiData = await res.json()
|
const apiData = await res.json()
|
||||||
@@ -268,7 +275,9 @@ function StreamPage() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="w-16 h-16 border-4 border-white/20 border-t-white rounded-full animate-spin" />
|
<div className="w-16 h-16 border-4 border-white/20 border-t-white rounded-full animate-spin" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : isActuallyLive && activePlayback ? (
|
) : isActuallyLive && activePlayback ? (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
@@ -282,7 +291,9 @@ function StreamPage() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="w-16 h-16 border-4 border-white/20 border-t-red-500 rounded-full animate-spin" />
|
<div className="w-16 h-16 border-4 border-white/20 border-t-red-500 rounded-full animate-spin" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showReadyPulse && (
|
{showReadyPulse && (
|
||||||
@@ -305,7 +316,11 @@ function StreamPage() {
|
|||||||
</p>
|
</p>
|
||||||
{profileUser.website && (
|
{profileUser.website && (
|
||||||
<a
|
<a
|
||||||
href={profileUser.website.startsWith("http") ? profileUser.website : `https://${profileUser.website}`}
|
href={
|
||||||
|
profileUser.website.startsWith("http")
|
||||||
|
? profileUser.website
|
||||||
|
: `https://${profileUser.website}`
|
||||||
|
}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="mt-8 text-2xl md:text-3xl font-medium text-white hover:text-neutral-300 transition-colors"
|
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="flex-shrink-0 border-t border-white/10">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="px-6 py-6 border-b border-white/10">
|
<div className="px-6 py-6 border-b border-white/10">
|
||||||
<h2 className="text-xl font-bold text-white">Past Streams</h2>
|
<h2 className="text-xl font-bold text-white">
|
||||||
<p className="text-sm text-white/60 mt-1">Watch previous recordings</p>
|
Past Streams
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-white/60 mt-1">
|
||||||
|
Watch previous recordings
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{replaysLoading ? (
|
{replaysLoading ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<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 className="w-8 h-8 border-4 border-white/20 border-t-white rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : showPaywall ? (
|
) : PAYWALL_ENABLED && showPaywall ? (
|
||||||
<PaywallBanner
|
<PaywallBanner
|
||||||
creatorName={profileUser.name || profileUser.username}
|
creatorName={profileUser.name || profileUser.username}
|
||||||
creatorUsername={profileUser.username}
|
creatorUsername={profileUser.username}
|
||||||
@@ -345,10 +364,7 @@ function StreamPage() {
|
|||||||
|
|
||||||
{/* Desktop Profile Sidebar with Chat */}
|
{/* Desktop Profile Sidebar with Chat */}
|
||||||
<div className="hidden md:flex w-96 h-full flex-shrink-0">
|
<div className="hidden md:flex w-96 h-full flex-shrink-0">
|
||||||
<ProfileSidebar
|
<ProfileSidebar user={profileUser} isLive={isActuallyLive}>
|
||||||
user={profileUser}
|
|
||||||
isLive={isActuallyLive}
|
|
||||||
>
|
|
||||||
<CommentBox username={username} />
|
<CommentBox username={username} />
|
||||||
</ProfileSidebar>
|
</ProfileSidebar>
|
||||||
</div>
|
</div>
|
||||||
@@ -415,10 +431,7 @@ function StreamPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 overflow-auto">
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
<ProfileSidebar
|
<ProfileSidebar user={profileUser} isLive={isActuallyLive} />
|
||||||
user={profileUser}
|
|
||||||
isLive={isActuallyLive}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { authClient } from "@/lib/auth-client"
|
|||||||
import { useAccount } from "jazz-tools/react"
|
import { useAccount } from "jazz-tools/react"
|
||||||
import { ViewerAccount, type SavedUrl } from "@/lib/jazz/schema"
|
import { ViewerAccount, type SavedUrl } from "@/lib/jazz/schema"
|
||||||
import { JazzProvider } from "@/lib/jazz/provider"
|
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
|
// Feature flag: only this email can access stream features
|
||||||
const STREAM_ENABLED_EMAIL = "nikita@nikiv.dev"
|
const STREAM_ENABLED_EMAIL = "nikita@nikiv.dev"
|
||||||
@@ -154,6 +154,23 @@ function Dashboard() {
|
|||||||
</div>
|
</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 */}
|
{/* Saved Links */}
|
||||||
<div className="bg-neutral-900/50 border border-white/5 rounded-2xl p-6">
|
<div className="bg-neutral-900/50 border border-white/5 rounded-2xl p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
|||||||
@@ -1,388 +1,562 @@
|
|||||||
import { useState, useEffect, useCallback } from "react"
|
import { useState, type FormEvent } from "react"
|
||||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { authClient } from "@/lib/auth-client"
|
import { authClient } from "@/lib/auth-client"
|
||||||
|
import { useAccount } from "jazz-tools/react"
|
||||||
import {
|
import {
|
||||||
Search,
|
ViewerAccount,
|
||||||
Star,
|
BrowserSession,
|
||||||
|
BrowserSessionList,
|
||||||
|
type BrowserTab,
|
||||||
|
} from "@/lib/jazz/schema"
|
||||||
|
import {
|
||||||
|
Layers,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Trash2,
|
Upload,
|
||||||
Clock,
|
Clock,
|
||||||
Globe,
|
Tag,
|
||||||
|
Search,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type { BrowserSession, BrowserSessionTab } from "@/db/schema"
|
import { JazzProvider } from "@/lib/jazz/provider"
|
||||||
|
|
||||||
export const Route = createFileRoute("/sessions")({
|
export const Route = createFileRoute("/sessions")({
|
||||||
component: BrowserSessionsPage,
|
component: SessionsPageWrapper,
|
||||||
ssr: false,
|
ssr: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
interface SessionWithTabs extends BrowserSession {
|
function SessionsPageWrapper() {
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-zinc-900/50 border border-zinc-800 rounded-xl overflow-hidden">
|
<JazzProvider>
|
||||||
<div
|
<SessionsPage />
|
||||||
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-zinc-800/50 transition-colors"
|
</JazzProvider>
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrowserSessionsPage() {
|
function SessionsPage() {
|
||||||
const { data: session, isPending: authPending } = authClient.useSession()
|
const { data: session, isPending: authPending } = authClient.useSession()
|
||||||
const [sessions, setSessions] = useState<SessionWithTabs[]>([])
|
const me = useAccount(ViewerAccount)
|
||||||
const [pagination, setPagination] = useState<PaginationInfo>({
|
|
||||||
page: 1,
|
|
||||||
limit: 50,
|
|
||||||
total: 0,
|
|
||||||
totalPages: 0,
|
|
||||||
})
|
|
||||||
const [search, setSearch] = useState("")
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
const fetchSessions = useCallback(
|
const [isAdding, setIsAdding] = useState(false)
|
||||||
async (page = 1, searchQuery = "") => {
|
const [isImporting, setIsImporting] = useState(false)
|
||||||
setLoading(true)
|
const [expandedSessions, setExpandedSessions] = useState<Set<number>>(new Set())
|
||||||
try {
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const body = {
|
|
||||||
action: "list" as const,
|
|
||||||
page,
|
|
||||||
limit: pagination.limit,
|
|
||||||
search: searchQuery || undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch("/api/browser-sessions", {
|
// New session form state
|
||||||
method: "POST",
|
const [newName, setNewName] = useState("")
|
||||||
headers: { "Content-Type": "application/json" },
|
const [newDescription, setNewDescription] = useState("")
|
||||||
body: JSON.stringify(body),
|
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) {
|
// Import state
|
||||||
const data = await res.json()
|
const [importJson, setImportJson] = useState("")
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authPending) {
|
if (authPending) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-black text-white grid place-items-center">
|
<div className="min-h-screen text-white grid place-items-center">
|
||||||
<p className="text-zinc-500">Loading...</p>
|
<p className="text-slate-400">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-black text-white grid place-items-center">
|
<div className="min-h-screen text-white grid place-items-center">
|
||||||
<div className="text-center">
|
<div className="text-center space-y-4">
|
||||||
<p className="text-zinc-400 mb-4">Sign in to view your browser sessions</p>
|
<p className="text-slate-400">Please sign in to save browser sessions</p>
|
||||||
<Link
|
<a
|
||||||
to="/login"
|
href="/auth"
|
||||||
className="inline-block px-4 py-2 bg-white text-black rounded-lg font-medium hover:bg-zinc-200 transition-colors"
|
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
|
Sign in
|
||||||
</Link>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group sessions by date
|
const root = me.$isLoaded ? me.root : null
|
||||||
const sessionsByDate = sessions.reduce(
|
|
||||||
(acc, s) => {
|
if (!me.$isLoaded || !root?.$isLoaded) {
|
||||||
const date = formatDate(s.captured_at)
|
return (
|
||||||
if (!acc[date]) acc[date] = []
|
<div className="min-h-screen text-white grid place-items-center">
|
||||||
acc[date].push(s)
|
<p className="text-slate-400">Loading Jazz...</p>
|
||||||
return acc
|
</div>
|
||||||
},
|
)
|
||||||
{} as Record<string, SessionWithTabs[]>,
|
}
|
||||||
)
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="min-h-screen bg-black text-white">
|
<div className="min-h-screen text-white">
|
||||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
<div className="max-w-3xl mx-auto px-4 py-10">
|
||||||
<header className="mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<h1 className="text-2xl font-bold mb-2">Browser Sessions</h1>
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-zinc-500">
|
<Layers className="w-6 h-6 text-teal-400" />
|
||||||
{pagination.total} sessions saved
|
<h1 className="text-2xl font-semibold">Browser Sessions</h1>
|
||||||
</p>
|
<span className="text-sm text-white/50">({allSessions.length})</span>
|
||||||
</header>
|
</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 */}
|
{/* Search */}
|
||||||
<div className="relative mb-6">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Search sessions..."
|
placeholder="Search sessions, tabs, or tags..."
|
||||||
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"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Sessions list */}
|
{/* Import Modal */}
|
||||||
{loading ? (
|
{isImporting && (
|
||||||
<div className="text-center py-12 text-zinc-500">Loading sessions...</div>
|
<form
|
||||||
) : sessions.length === 0 ? (
|
onSubmit={handleImport}
|
||||||
<div className="text-center py-12 text-zinc-500">
|
className="mb-6 p-4 bg-[#0c0f18] border border-white/10 rounded-xl space-y-4"
|
||||||
{search ? `No sessions found for "${search}"` : "No sessions yet"}
|
>
|
||||||
</div>
|
<h3 className="text-lg font-medium">Import Session from JSON</h3>
|
||||||
) : (
|
<p className="text-sm text-white/60">
|
||||||
<div className="space-y-6">
|
Paste JSON with format: {`[{title, url}, ...]`} or {`{name, tabs: [{title, url}, ...]}`}
|
||||||
{Object.entries(sessionsByDate).map(([date, dateSessions]) => (
|
</p>
|
||||||
<section key={date}>
|
<textarea
|
||||||
<h2 className="text-sm font-medium text-zinc-400 mb-3">{date}</h2>
|
value={importJson}
|
||||||
<div className="space-y-2">
|
onChange={(e) => setImportJson(e.target.value)}
|
||||||
{dateSessions.map((s) => (
|
placeholder='[{"title": "Example", "url": "https://example.com"}]'
|
||||||
<SessionCard
|
rows={6}
|
||||||
key={s.id}
|
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"
|
||||||
session={s}
|
/>
|
||||||
onToggleFavorite={handleToggleFavorite}
|
<div className="flex justify-end gap-2">
|
||||||
onDelete={handleDelete}
|
<button
|
||||||
/>
|
type="button"
|
||||||
))}
|
onClick={() => {
|
||||||
</div>
|
setIsImporting(false)
|
||||||
</section>
|
setImportJson("")
|
||||||
))}
|
}}
|
||||||
</div>
|
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 */}
|
{/* Add Session Form */}
|
||||||
{pagination.totalPages > 1 && (
|
{isAdding && (
|
||||||
<nav className="flex items-center justify-center gap-2 mt-8">
|
<form
|
||||||
<button
|
onSubmit={handleSaveSession}
|
||||||
type="button"
|
className="mb-6 p-4 bg-[#0c0f18] border border-white/10 rounded-xl space-y-4"
|
||||||
onClick={() => fetchSessions(pagination.page - 1, search)}
|
>
|
||||||
disabled={pagination.page <= 1}
|
<h3 className="text-lg font-medium">New Browser Session</h3>
|
||||||
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"
|
|
||||||
>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
Previous
|
<div className="space-y-2">
|
||||||
</button>
|
<label className="text-sm text-white/70">Session Name</label>
|
||||||
<span className="px-3 text-sm text-zinc-500">
|
<input
|
||||||
Page {pagination.page} of {pagination.totalPages}
|
type="text"
|
||||||
</span>
|
required
|
||||||
<button
|
value={newName}
|
||||||
type="button"
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
onClick={() => fetchSessions(pagination.page + 1, search)}
|
placeholder="Research tabs"
|
||||||
disabled={pagination.page >= pagination.totalPages}
|
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"
|
||||||
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"
|
/>
|
||||||
>
|
</div>
|
||||||
Next
|
<div className="space-y-2">
|
||||||
</button>
|
<label className="text-sm text-white/70">Browser</label>
|
||||||
</nav>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user