mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-26 18:28:35 +02:00
Add URL management schema, route, and related updates for URLs feature
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getStreamByUsername, type StreamPageData } from "@/lib/stream/db"
|
||||
import { VideoPlayer } from "@/components/VideoPlayer"
|
||||
@@ -21,6 +21,7 @@ export const Route = createFileRoute("/$username")({
|
||||
// Cloudflare Stream HLS URL
|
||||
const HLS_URL = "https://customer-xctsztqzu046isdc.cloudflarestream.com/bb7858eafc85de6c92963f3817477b5d/manifest/video.m3u8"
|
||||
const NIKIV_PLAYBACK = resolveStreamPlayback({ hlsUrl: HLS_URL, webrtcUrl: null })
|
||||
const READY_PULSE_MS = 1200
|
||||
|
||||
// Hardcoded user for nikiv
|
||||
const NIKIV_DATA: StreamPageData = {
|
||||
@@ -58,6 +59,8 @@ function StreamPage() {
|
||||
const [nowPlayingLoading, setNowPlayingLoading] = useState(false)
|
||||
const [nowPlayingError, setNowPlayingError] = useState(false)
|
||||
const [streamLive, setStreamLive] = useState(false)
|
||||
const [showReadyPulse, setShowReadyPulse] = useState(false)
|
||||
const readyPulseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
@@ -147,6 +150,31 @@ function StreamPage() {
|
||||
}
|
||||
}, [username])
|
||||
|
||||
useEffect(() => {
|
||||
if (readyPulseTimeoutRef.current) {
|
||||
clearTimeout(readyPulseTimeoutRef.current)
|
||||
readyPulseTimeoutRef.current = null
|
||||
}
|
||||
|
||||
if (!streamReady) {
|
||||
setShowReadyPulse(false)
|
||||
return
|
||||
}
|
||||
|
||||
setShowReadyPulse(true)
|
||||
readyPulseTimeoutRef.current = setTimeout(() => {
|
||||
setShowReadyPulse(false)
|
||||
readyPulseTimeoutRef.current = null
|
||||
}, READY_PULSE_MS)
|
||||
|
||||
return () => {
|
||||
if (readyPulseTimeoutRef.current) {
|
||||
clearTimeout(readyPulseTimeoutRef.current)
|
||||
readyPulseTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [streamReady])
|
||||
|
||||
const stream = data?.stream ?? null
|
||||
const playback = stream?.playback ?? null
|
||||
const fallbackPlayback = stream?.hls_url
|
||||
@@ -350,13 +378,13 @@ function StreamPage() {
|
||||
}}
|
||||
/>
|
||||
{!streamReady && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-white">
|
||||
<div className="text-center">
|
||||
<div className="animate-pulse text-4xl">🔴</div>
|
||||
<p className="mt-4 text-xl text-neutral-400">
|
||||
Connecting to stream...
|
||||
</p>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-black/70">
|
||||
<div className="animate-pulse text-4xl">🟡</div>
|
||||
</div>
|
||||
)}
|
||||
{showReadyPulse && (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||
<div className="animate-pulse text-4xl">🔴</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -369,27 +397,29 @@ function StreamPage() {
|
||||
onReady={() => setStreamReady(true)}
|
||||
/>
|
||||
{!streamReady && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-white">
|
||||
<div className="text-center">
|
||||
<div className="animate-pulse text-4xl">🔴</div>
|
||||
<p className="mt-4 text-xl text-neutral-400">
|
||||
Connecting to stream...
|
||||
</p>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-black/70">
|
||||
<div className="animate-pulse text-4xl">🟡</div>
|
||||
</div>
|
||||
)}
|
||||
{showReadyPulse && (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||
<div className="animate-pulse text-4xl">🔴</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<VideoPlayer src={activePlayback.url} muted={false} />
|
||||
<div className="relative h-full w-full">
|
||||
<VideoPlayer src={activePlayback.url} muted={false} />
|
||||
{showReadyPulse && (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||
<div className="animate-pulse text-4xl">🔴</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : isActuallyLive && activePlayback ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-white">
|
||||
<div className="text-center">
|
||||
<div className="animate-pulse text-4xl">🔴</div>
|
||||
<p className="mt-4 text-xl text-neutral-400">
|
||||
Connecting to stream...
|
||||
</p>
|
||||
</div>
|
||||
<div className="animate-pulse text-4xl">🟡</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-white">
|
||||
|
||||
191
packages/web/src/routes/urls.tsx
Normal file
191
packages/web/src/routes/urls.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
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 { ViewerAccount, type SavedUrl } from "@/lib/jazz/schema"
|
||||
import { Link2, Plus, Trash2, ExternalLink } from "lucide-react"
|
||||
|
||||
export const Route = createFileRoute("/urls")({
|
||||
component: UrlsPage,
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
function UrlsPage() {
|
||||
const { data: session, isPending: authPending } = authClient.useSession()
|
||||
const me = useAccount(ViewerAccount)
|
||||
|
||||
const [newUrl, setNewUrl] = useState("")
|
||||
const [newTitle, setNewTitle] = useState("")
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
|
||||
if (authPending) {
|
||||
return (
|
||||
<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 text-white grid place-items-center">
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-slate-400">Please sign in to save URLs</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
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const root = me.$isLoaded ? me.root : null
|
||||
const urlList = root?.$isLoaded ? root.savedUrls : 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>
|
||||
)
|
||||
}
|
||||
|
||||
const savedUrls: SavedUrl[] = urlList?.$isLoaded ? [...urlList] : []
|
||||
|
||||
const handleAddUrl = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newUrl.trim() || !root?.savedUrls?.$isLoaded) return
|
||||
|
||||
root.savedUrls.$jazz.push({
|
||||
url: newUrl.trim(),
|
||||
title: newTitle.trim() || null,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
|
||||
setNewUrl("")
|
||||
setNewTitle("")
|
||||
setIsAdding(false)
|
||||
}
|
||||
|
||||
const handleDeleteUrl = (index: number) => {
|
||||
if (!root?.savedUrls?.$isLoaded) return
|
||||
root.savedUrls.$jazz.splice(index, 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen text-white">
|
||||
<div className="max-w-2xl mx-auto px-4 py-10">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link2 className="w-6 h-6 text-teal-400" />
|
||||
<h1 className="text-2xl font-semibold">Saved URLs</h1>
|
||||
</div>
|
||||
<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" />
|
||||
Add URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isAdding && (
|
||||
<form
|
||||
onSubmit={handleAddUrl}
|
||||
className="mb-6 p-4 bg-[#0c0f18] border border-white/10 rounded-xl space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-white/70">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
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">Title (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="My favorite site"
|
||||
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="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsAdding(false)
|
||||
setNewUrl("")
|
||||
setNewTitle("")
|
||||
}}
|
||||
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"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{savedUrls.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<Link2 className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No saved URLs yet</p>
|
||||
<p className="text-sm mt-1">Click "Add URL" to save your first link</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{savedUrls.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 p-4 bg-[#0c0f18] border border-white/5 rounded-xl hover:border-white/10 transition-colors group"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{item.title || item.url}
|
||||
</p>
|
||||
{item.title && (
|
||||
<p className="text-xs text-white/50 truncate mt-1">
|
||||
{item.url}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteUrl(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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user