mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 11:50:25 +01: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>
|
||||
|
||||
/**
|
||||
* 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([]),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user