Merge remote-tracking branch 'origin/main' into master
This commit is contained in:
+33
-111
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, Fragment } from "react"
|
||||
import { Send, Bot, User, RefreshCw, AlertCircle, Check, Terminal } from "lucide-react"
|
||||
import { Send, Loader2, Bot, User, RefreshCw, AlertCircle } from "lucide-react"
|
||||
|
||||
function linkifyText(text: string) {
|
||||
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
||||
@@ -24,26 +24,9 @@ export function AIChat() {
|
||||
const [input, setInput] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
|
||||
const [ollamaStatus, setOllamaStatus] = useState<boolean | null>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const checkServer = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/ai/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: "ping" }),
|
||||
})
|
||||
if (res.status !== 503) {
|
||||
setBootState("ready")
|
||||
} else {
|
||||
setTimeout(checkServer, 2000)
|
||||
}
|
||||
} catch {
|
||||
setTimeout(checkServer, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/ai/jobs")
|
||||
.then((r) => r.json())
|
||||
@@ -73,13 +56,29 @@ export function AIChat() {
|
||||
},
|
||||
])
|
||||
})
|
||||
checkServer()
|
||||
}, [])
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages])
|
||||
|
||||
const checkOllama = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/ai/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: "__ping__" }),
|
||||
})
|
||||
setOllamaStatus(res.status !== 503)
|
||||
} catch {
|
||||
setOllamaStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { checkOllama() }, [])
|
||||
|
||||
const sendMessage = async () => {
|
||||
const msg = input.trim()
|
||||
if (!msg || loading) return
|
||||
@@ -97,8 +96,8 @@ export function AIChat() {
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || `Error ${res.status}`)
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || "Failed to get response")
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
@@ -122,86 +121,17 @@ export function AIChat() {
|
||||
}
|
||||
}
|
||||
|
||||
const bootOverlay = (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<style>{`
|
||||
@keyframes walk {
|
||||
0%, 100% { transform: translateX(0) translateY(0); }
|
||||
25% { transform: translateX(12px) translateY(-4px); }
|
||||
50% { transform: translateX(24px) translateY(0); }
|
||||
75% { transform: translateX(12px) translateY(-4px); }
|
||||
}
|
||||
@keyframes legLeft {
|
||||
0%, 100% { transform: rotate(-10deg); }
|
||||
50% { transform: rotate(10deg); }
|
||||
}
|
||||
@keyframes legRight {
|
||||
0%, 100% { transform: rotate(10deg); }
|
||||
50% { transform: rotate(-10deg); }
|
||||
}
|
||||
@keyframes armLeft {
|
||||
0%, 100% { transform: rotate(15deg); }
|
||||
50% { transform: rotate(-15deg); }
|
||||
}
|
||||
@keyframes armRight {
|
||||
0%, 100% { transform: rotate(-15deg); }
|
||||
50% { transform: rotate(15deg); }
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 45%, 55%, 100% { height: 4px; }
|
||||
50% { height: 1px; }
|
||||
}
|
||||
.robot-walk { animation: walk 0.6s ease-in-out infinite; }
|
||||
.robot-leg-l { transform-origin: top center; animation: legLeft 0.3s ease-in-out infinite; }
|
||||
.robot-leg-r { transform-origin: top center; animation: legRight 0.3s ease-in-out infinite; }
|
||||
.robot-arm-l { transform-origin: top center; animation: armLeft 0.3s ease-in-out infinite; }
|
||||
.robot-arm-r { transform-origin: top center; animation: armRight 0.3s ease-in-out infinite; }
|
||||
.robot-eye { animation: blink 2s ease-in-out infinite; }
|
||||
`}</style>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-sm text-[#6a6a75]">Servers booting...</p>
|
||||
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center overflow-hidden">
|
||||
<div className="robot-walk relative">
|
||||
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
|
||||
<rect x="10" y="2" width="20" height="16" rx="3" fill="#1BB0CE" opacity="0.9"/>
|
||||
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="#1BB0CE" opacity="0.7"/>
|
||||
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="#1BB0CE" opacity="0.7"/>
|
||||
<rect x="14" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
|
||||
<rect x="22" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
|
||||
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
|
||||
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
|
||||
<rect x="17" y="10" width="6" height="2" rx="1" fill="#0d1117"/>
|
||||
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="#1BB0CE" opacity="0.8"/>
|
||||
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="#1BB0CE" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const readyOverlay = (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center">
|
||||
<Check className="h-6 w-6 text-green-400" />
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-xs text-[#6a6a75]">Try these commands:</p>
|
||||
<div className="flex gap-2">
|
||||
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> lists</code>
|
||||
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> leads</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (bootState === "booting") return bootOverlay
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{bootState === "ready" && readyOverlay}
|
||||
{ollamaStatus === false && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs">
|
||||
<AlertCircle className="h-3.5 w-3.5 flex-none" />
|
||||
<span className="flex-1">Ollama not responding. Start it with <code className="bg-amber-500/20 px-1 rounded">ollama serve</code></span>
|
||||
<button type="button" onClick={checkOllama} className="hover:text-amber-300">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
|
||||
{messages.map((msg, i) => (
|
||||
@@ -233,10 +163,7 @@ export function AIChat() {
|
||||
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
||||
</div>
|
||||
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
|
||||
<svg className="h-4 w-4 animate-spin text-[#1BB0CE]" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[#1BB0CE]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -265,15 +192,10 @@ export function AIChat() {
|
||||
disabled={loading || !input.trim()}
|
||||
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
|
||||
</svg>
|
||||
) : <Send className="h-4 w-4" />}
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Search, Image, Sticker, X, Loader2, TrendingUp } from "lucide-react"
|
||||
import data from "@emoji-mart/data"
|
||||
import Picker from "@emoji-mart/react"
|
||||
import { getStickerPacks, type StickerPack } from "@/data/stickers"
|
||||
|
||||
type Tab = "emoji" | "gif" | "sticker"
|
||||
|
||||
interface MediaPickerProps {
|
||||
onEmojiSelect: (emoji: string) => void
|
||||
onMediaSelect: (content: string) => void
|
||||
onClose: () => void
|
||||
theme?: string
|
||||
}
|
||||
|
||||
const RECENT_GIFS_KEY = "recent-gifs"
|
||||
const RECENT_STICKERS_KEY = "recent-stickers"
|
||||
const MAX_RECENT = 12
|
||||
|
||||
function getRecent(key: string): string[] {
|
||||
if (typeof window === "undefined") return []
|
||||
try { return JSON.parse(localStorage.getItem(key) || "[]") } catch { return [] }
|
||||
}
|
||||
|
||||
function addRecent(key: string, id: string) {
|
||||
if (typeof window === "undefined") return
|
||||
const items = [id, ...getRecent(key).filter((x) => x !== id)].slice(0, MAX_RECENT)
|
||||
localStorage.setItem(key, JSON.stringify(items))
|
||||
}
|
||||
|
||||
export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, theme }: MediaPickerProps) {
|
||||
const [tab, setTab] = useState<Tab>("emoji")
|
||||
const [gifQuery, setGifQuery] = useState("")
|
||||
const [gifResults, setGifResults] = useState<any[]>([])
|
||||
const [gifLoading, setGifLoading] = useState(false)
|
||||
const [gifError, setGifError] = useState("")
|
||||
const [gifNoKey, setGifNoKey] = useState(false)
|
||||
const [gifNext, setGifNext] = useState("")
|
||||
const [recentGifs, setRecentGifs] = useState<string[]>([])
|
||||
const [recentStickers, setRecentStickers] = useState<string[]>([])
|
||||
const [stickerPacks] = useState<StickerPack[]>(getStickerPacks)
|
||||
const [activePack, setActivePack] = useState(stickerPacks[0]?.id || "")
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) onClose()
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick)
|
||||
return () => document.removeEventListener("mousedown", handleClick)
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => { setRecentGifs(getRecent(RECENT_GIFS_KEY)) }, [])
|
||||
useEffect(() => { setRecentStickers(getRecent(RECENT_STICKERS_KEY)) }, [])
|
||||
|
||||
const fetchGifs = useCallback(async (query: string, pos = "") => {
|
||||
setGifLoading(true)
|
||||
setGifError("")
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: "20", pos })
|
||||
if (query) params.set("q", query)
|
||||
const res = await fetch(`/api/gifs?${params}`)
|
||||
const data = await res.json()
|
||||
if (data.noKey) {
|
||||
setGifNoKey(true)
|
||||
setGifResults([])
|
||||
} else if (data.results) {
|
||||
setGifResults((prev) => (pos ? [...prev, ...data.results] : data.results))
|
||||
setGifNext(data.next || "")
|
||||
setGifNoKey(false)
|
||||
} else {
|
||||
setGifError("No results found")
|
||||
}
|
||||
} catch {
|
||||
setGifError("Failed to load GIFs")
|
||||
}
|
||||
setGifLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== "gif") return
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current)
|
||||
if (gifQuery.trim()) {
|
||||
searchTimer.current = setTimeout(() => fetchGifs(gifQuery.trim()), 400)
|
||||
} else {
|
||||
fetchGifs("")
|
||||
}
|
||||
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
|
||||
}, [tab, gifQuery, fetchGifs])
|
||||
|
||||
const handleGifSelect = (gif: any) => {
|
||||
const content = JSON.stringify({ gif: gif.url, w: gif.width, h: gif.height })
|
||||
addRecent(RECENT_GIFS_KEY, gif.id)
|
||||
setRecentGifs(getRecent(RECENT_GIFS_KEY))
|
||||
onMediaSelect(content)
|
||||
}
|
||||
|
||||
const handleStickerSelect = (sticker: any) => {
|
||||
const content = sticker.svg
|
||||
? JSON.stringify({ sticker: sticker.svg, w: 200, h: 200, id: sticker.id })
|
||||
: JSON.stringify({ sticker: sticker.emoji, w: 120, h: 120, id: sticker.id })
|
||||
addRecent(RECENT_STICKERS_KEY, sticker.id)
|
||||
setRecentStickers(getRecent(RECENT_STICKERS_KEY))
|
||||
onMediaSelect(content)
|
||||
}
|
||||
|
||||
const activeStickers = stickerPacks.find((p) => p.id === activePack)?.stickers || []
|
||||
|
||||
const renderGifGrid = (items: any[], emptyMsg: string) => {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
|
||||
<Image className="h-8 w-8 opacity-40" />
|
||||
<span>{emptyMsg}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{items.map((gif: any) => (
|
||||
<button
|
||||
key={gif.id}
|
||||
type="button"
|
||||
onClick={() => handleGifSelect(gif)}
|
||||
className="rounded-lg overflow-hidden bg-muted/50 hover:ring-2 hover:ring-primary/50 transition-all aspect-video"
|
||||
>
|
||||
<img src={gif.previewUrl || gif.url} alt={gif.title || "GIF"} className="w-full h-full object-cover" loading="lazy" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={pickerRef} className="w-[360px] rounded-xl border bg-popover shadow-xl overflow-hidden">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b">
|
||||
<button type="button" onClick={() => setTab("emoji")} className={cn("flex-1 py-2.5 text-sm font-medium transition-colors relative", tab === "emoji" ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
|
||||
Emoji
|
||||
{tab === "emoji" && <div className="absolute bottom-0 left-4 right-4 h-0.5 bg-primary rounded-full" />}
|
||||
</button>
|
||||
<button type="button" onClick={() => setTab("gif")} className={cn("flex-1 py-2.5 text-sm font-medium transition-colors relative", tab === "gif" ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
|
||||
GIFs
|
||||
{tab === "gif" && <div className="absolute bottom-0 left-4 right-4 h-0.5 bg-primary rounded-full" />}
|
||||
</button>
|
||||
<button type="button" onClick={() => setTab("sticker")} className={cn("flex-1 py-2.5 text-sm font-medium transition-colors relative", tab === "sticker" ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
|
||||
Stickers
|
||||
{tab === "sticker" && <div className="absolute bottom-0 left-4 right-4 h-0.5 bg-primary rounded-full" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Emoji Tab */}
|
||||
{tab === "emoji" && (
|
||||
<div className="max-h-[380px] overflow-hidden">
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={(emoji: any) => { onEmojiSelect(emoji.native); onClose() }}
|
||||
theme={theme === "dark" ? "dark" : "light"}
|
||||
previewPosition="none"
|
||||
skinTonePosition="none"
|
||||
set="native"
|
||||
emojiSize={32}
|
||||
maxFrequentRows={2}
|
||||
perLine={8}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GIF Tab */}
|
||||
{tab === "gif" && (
|
||||
<div className="flex flex-col h-[380px]">
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={gifQuery}
|
||||
onChange={(e) => setGifQuery(e.target.value)}
|
||||
placeholder="Search GIFs..."
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
{gifQuery && (
|
||||
<button type="button" onClick={() => setGifQuery("")} className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{gifLoading && gifResults.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : gifNoKey ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
|
||||
<Image className="h-8 w-8 opacity-40" />
|
||||
<span>GIF search requires a Tenor API key</span>
|
||||
<span className="text-xs opacity-60">Set TENOR_API_KEY in .env.local</span>
|
||||
</div>
|
||||
) : gifError ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
|
||||
<TrendingUp className="h-8 w-8 opacity-40" />
|
||||
<span>{gifError}</span>
|
||||
<button type="button" onClick={() => fetchGifs(gifQuery)} className="text-xs text-primary hover:underline">Retry</button>
|
||||
</div>
|
||||
) : gifQuery.trim() ? (
|
||||
renderGifGrid(gifResults, "No GIFs found")
|
||||
) : recentGifs.length > 0 && gifResults.length === 0 ? (
|
||||
<>
|
||||
<div className="text-xs font-medium text-muted-foreground px-1 pb-2">Recent GIFs</div>
|
||||
{renderGifGrid(
|
||||
recentGifs.map((id) => gifResults.find((g: any) => g.id === id)).filter(Boolean),
|
||||
"No recent GIFs"
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs font-medium text-muted-foreground px-1 pb-2">Trending</div>
|
||||
{renderGifGrid(gifResults, "No trending GIFs available")}
|
||||
</>
|
||||
)}
|
||||
{gifNext && !gifLoading && (
|
||||
<div className="flex justify-center pt-2 pb-1">
|
||||
<Button type="button" variant="ghost" size="sm" className="text-xs h-7" onClick={() => fetchGifs(gifQuery, gifNext)}>
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stickers Tab */}
|
||||
{tab === "sticker" && (
|
||||
<div className="flex flex-col h-[380px]">
|
||||
<div className="flex gap-1 p-2 border-b overflow-x-auto shrink-0">
|
||||
{stickerPacks.map((pack) => (
|
||||
<button
|
||||
key={pack.id}
|
||||
type="button"
|
||||
onClick={() => setActivePack(pack.id)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs rounded-md whitespace-nowrap transition-colors",
|
||||
activePack === pack.id ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{pack.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{activeStickers.map((sticker) => (
|
||||
<button
|
||||
key={sticker.id}
|
||||
type="button"
|
||||
onClick={() => handleStickerSelect(sticker)}
|
||||
className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square"
|
||||
>
|
||||
{sticker.svg ? (
|
||||
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sticker.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
|
||||
) : (
|
||||
<span className="text-5xl">{sticker.emoji}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{recentStickers.length > 0 && (
|
||||
<>
|
||||
<div className="text-xs font-medium text-muted-foreground pt-4 pb-2">Recently Used</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{recentStickers.map((id) => {
|
||||
const s = stickerPacks.flatMap((p) => p.stickers).find((x) => x.id === id)
|
||||
if (!s) return null
|
||||
return (
|
||||
<button key={s.id} type="button" onClick={() => handleStickerSelect(s)} className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square opacity-70 hover:opacity-100">
|
||||
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: s.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
export function CrossedLightsabers() {
|
||||
return (
|
||||
<svg
|
||||
width="120"
|
||||
height="120"
|
||||
viewBox="0 0 200 200"
|
||||
className="pointer-events-none select-none overflow-visible shrink-0"
|
||||
style={{ overflow: "visible" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="purpleGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="#a855f7" stopOpacity="0.6" />
|
||||
<stop offset="40%" stopColor="#a855f7" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#a855f7" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<circle cx="100" cy="73" r="24" fill="url(#purpleGlow)" className="animate-pulse-intersection" />
|
||||
|
||||
<g className="animate-pulse-red">
|
||||
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-outer" />
|
||||
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-mid" />
|
||||
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" className="stroke-[#f87171] dark:stroke-[#fca5a5] saber-core" />
|
||||
</g>
|
||||
|
||||
<g className="animate-pulse-blue">
|
||||
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-outer" />
|
||||
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-mid" />
|
||||
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" className="stroke-[#60a5fa] dark:stroke-[#93c5fd] saber-blue-core" />
|
||||
</g>
|
||||
|
||||
<rect x="30" y="145" width="10" height="55" rx="2" fill="#374151" />
|
||||
<rect x="160" y="145" width="10" height="55" rx="2" fill="#374151" />
|
||||
|
||||
<rect x="30" y="155" width="10" height="5" fill="#1f2937" />
|
||||
<rect x="30" y="166" width="10" height="5" fill="#1f2937" />
|
||||
<rect x="30" y="177" width="10" height="5" fill="#1f2937" />
|
||||
<rect x="160" y="155" width="10" height="5" fill="#1f2937" />
|
||||
<rect x="160" y="166" width="10" height="5" fill="#1f2937" />
|
||||
<rect x="160" y="177" width="10" height="5" fill="#1f2937" />
|
||||
|
||||
<rect x="32" y="143" width="6" height="4" rx="1" fill="#6b7280" />
|
||||
<rect x="162" y="143" width="6" height="4" rx="1" fill="#6b7280" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -69,19 +69,6 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
|
||||
className="h-full"
|
||||
>
|
||||
<Card className="h-full relative overflow-hidden">
|
||||
{/* Spider watermark */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.05]">
|
||||
<svg width="220" height="220" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="90" y1="0" x2="90" y2="180" stroke="#CC0000" strokeWidth="1" />
|
||||
<line x1="0" y1="90" x2="180" y2="90" stroke="#CC0000" strokeWidth="1" />
|
||||
<line x1="30" y1="30" x2="150" y2="150" stroke="#0033CC" strokeWidth="1" />
|
||||
<line x1="150" y1="30" x2="30" y2="150" stroke="#0033CC" strokeWidth="1" />
|
||||
<circle cx="90" cy="90" r="40" stroke="#CC0000" strokeWidth="1" fill="none" />
|
||||
<circle cx="90" cy="90" r="60" stroke="#0033CC" strokeWidth="0.8" fill="none" strokeDasharray="4 4" />
|
||||
<circle cx="90" cy="90" r="80" stroke="#CC0000" strokeWidth="0.5" fill="none" strokeDasharray="2 6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle>Lead Status</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
|
||||
interface Star {
|
||||
left: number
|
||||
top: number
|
||||
size: number
|
||||
delay: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
export function StarField() {
|
||||
const stars = useMemo<Star[]>(() => {
|
||||
const result: Star[] = []
|
||||
for (let i = 0; i < 160; i++) {
|
||||
result.push({
|
||||
left: Math.random() * 100,
|
||||
top: Math.random() * 100,
|
||||
size: 1.5 + Math.random() * 2,
|
||||
delay: Math.random() * 6,
|
||||
duration: 3 + Math.random() * 4,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none select-none z-0 overflow-hidden">
|
||||
{stars.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full bg-[#222222] dark:bg-white star-twinkle"
|
||||
style={{
|
||||
left: `${s.left}%`,
|
||||
top: `${s.top}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
opacity: 0.35 + Math.random() * 0.45,
|
||||
animationDelay: `${s.delay}s`,
|
||||
animationDuration: `${s.duration}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,14 +3,11 @@
|
||||
export function StatCardSkeleton() {
|
||||
return (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-20">
|
||||
<p className="text-[#CC0000] dark:text-[#FF1111] text-5xl font-['Bangers',cursive] animate-pulse">
|
||||
THWIP!
|
||||
</p>
|
||||
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm mt-3">
|
||||
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm">
|
||||
Loading your data...
|
||||
</p>
|
||||
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4">
|
||||
<div className="w-full h-full rounded-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] animate-[loading_1.5s_ease-in-out_infinite]" />
|
||||
<div className="w-full h-full rounded-full bg-sidebar animate-[loading_1.5s_ease-in-out_infinite]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -118,37 +118,6 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
className="relative"
|
||||
>
|
||||
{/* Web overlay decorative */}
|
||||
<div className="absolute inset-0 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.06]">
|
||||
<svg width="100%" height="100%" viewBox="0 0 180 180" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<line key={`wl-${i}`} x1="0" y1={i * 36} x2="180" y2={i * 36} stroke="#CC0000" strokeWidth="0.5" />
|
||||
))}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<line key={`wc-${i}`} x1={i * 36} y1="0" x2={i * 36} y2="180" stroke="#CC0000" strokeWidth="0.5" />
|
||||
))}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<line key={`wd-${i}`} x1="0" y1={i * 36} x2={180 - i * 36} y2="180" stroke="#0033CC" strokeWidth="0.5" />
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Comic action lines on hover */}
|
||||
<div className="absolute -inset-2 pointer-events-none z-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<svg width="100%" height="100%" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="100" y1="0" x2="100" y2="20" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="100" y1="180" x2="100" y2="200" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="0" y1="100" x2="20" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="180" y1="100" x2="200" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="30" y1="30" x2="44" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="170" y1="30" x2="156" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="30" y1="170" x2="44" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="170" y1="170" x2="156" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="100" cy="100" r="90" stroke="#CC0000" strokeWidth="0.5" strokeDasharray="4 4" opacity="0.4" />
|
||||
<circle cx="100" cy="100" r="80" stroke="#0033CC" strokeWidth="0.5" strokeDasharray="3 5" opacity="0.3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
|
||||
{/* Red/Blue gradient top strip */}
|
||||
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
|
||||
|
||||
@@ -35,47 +35,47 @@ export function AppShell({ children }: AppShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(circle, #e6202010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
|
||||
backgroundImage: "radial-gradient(circle, #CC000010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
|
||||
backgroundSize: "28px 28px, 14px 14px",
|
||||
backgroundPosition: "0 0, 7px 7px",
|
||||
}}
|
||||
>
|
||||
{/* Spider-Man top gradient bar */}
|
||||
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#e62020] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF6666] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
|
||||
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF1111] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
|
||||
|
||||
{/* Corner spider webs */}
|
||||
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="0" y1="0" x2="180" y2="0" stroke="#e62020" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="60" stroke="#e62020" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="120" stroke="#e62020" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="120" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="60" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="0" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||
<path d="M0,30 Q30,30 30,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,60 Q60,60 60,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,90 Q90,90 90,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,120 Q120,120 120,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,150 Q150,150 150,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,180 Q180,180 180,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
|
||||
<line x1="0" y1="0" x2="180" y2="0" stroke="#e62020" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="60" stroke="#e62020" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="120" stroke="#e62020" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="120" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="60" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="0" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||
<path d="M0,30 Q30,30 30,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,60 Q60,60 60,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,90 Q90,90 90,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,120 Q120,120 120,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,150 Q150,150 150,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,180 Q180,180 180,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
export function CrmIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 64 64"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#e5e7eb] dark:[stroke-width:1.5px]">
|
||||
<path className="dark:[stroke-linejoin:round]" d="m10.35 35.62s-.75.14-1-1.48-.58-8.61-.3-8.87a10 10 0 0 1 2.07-.63s0-7.54 2.67-11.86 5.4-6.09 10.65-7.56a29.38 29.38 0 0 1 15.18.3c4.41 1.18 8.7 4.62 9.89 8.8a49.27 49.27 0 0 1 1.49 9.55 9.53 9.53 0 0 1 2.52.9c.17.33.43 9.12 0 9.38a1.6 1.6 0 0 1 -.87.23s7.86 18 7.59 19-12.93 6.36-30.1 6.23-26.05-5.41-26.36-6.11 6.57-17.88 6.57-17.88z" />
|
||||
</g>
|
||||
<g className="fill-[#777777] dark:fill-white/[0.1] dark:stroke-[#d4d4d4] dark:[stroke-width:1px]">
|
||||
<path d="m13.24 27.92s-.6-7.65 1.3-11.91a19.6 19.6 0 0 1 7.75-8.23 20.47 20.47 0 0 1 6.51-1.45c.05.16.67 10.23.66 11.61a27 27 0 0 1 -.57 4s-4-2.44-8.16-1.38a10.47 10.47 0 0 0 -7.49 7.36z" />
|
||||
<path d="m30.11 22.18s.53-3.69.54-4.89-.8-10.82-.6-10.95a4.11 4.11 0 0 1 1.88 0c.07.16 1.07 11.66 1.28 13.22a9.33 9.33 0 0 1 .27 2.44 2.85 2.85 0 0 1 -3.37.18z" />
|
||||
<path d="m34.8 21.44a76.48 76.48 0 0 1 -1.4-9.35c-.17-3.62-.51-5.65-.06-5.83s6.66.17 10.42 3.45 4.06 5.61 3.81 5.66-2.52-1-2.64-.77-.15.8.06.92 2.69 1.1 2.82 1.26a4.18 4.18 0 0 1 .28.95 16.94 16.94 0 0 0 -3-1.13c-.12.17-.23.92 0 1s3 1.3 3 1.3l.11.79s-2.83-1.18-3-1.14-.36.8-.15.92 3.16 1.26 3.25 1.43.41 1.36.41 1.36l-1.71-.54s.31.87.48 1 1.3.42 1.31.76a31.35 31.35 0 0 1 .2 3.33c-.12 0-2.2-6.45-5.21-7.09s-8.98 1.72-8.98 1.72z" />
|
||||
<path d="m22.44 22.28c2.21 0 7.19 2.45 9.76 1.84s6.89-2.79 9.51-2.81 4.07 2.48 6.36 7.84 7.14 17.34 7 17.34-8.1-17.89-9.66-20.23-2.66-3-5.86-2.77-6.81 1-6.85 1.41 0 7.17.49 7.75 3.06.71 6.18.51 5-.33 5.77-.64a10.14 10.14 0 0 0 1.69-1l7.79 16.48a30.15 30.15 0 0 0 -2.94-2.56c-.21.05-.31.8-.19.88s3.67 3.33 4 3.7a12.43 12.43 0 0 1 1.61 2.63c-.12.17-.45.55-.66.47s-5-5.71-5.39-5.83-.45.6-.45.6 5.18 5.5 5.06 5.63a8.62 8.62 0 0 1 -1.15.57s-4.34-5-4.5-4.94-.69.6-.4.81a45.71 45.71 0 0 1 4.07 4.36 7 7 0 0 1 -1.2.4 46.49 46.49 0 0 0 -3.58-3.72c-.25 0-.61.39-.49.55s3.34 3.29 3.22 3.46a4 4 0 0 1 -1.16.37s-2.47-2.59-2.65-2.59-.74.31-.53.6 2.39 2.19 2.27 2.28a4.86 4.86 0 0 1 -1 .19c-.13 0-2-2-2.22-2s-.78.19-.65.48 1.83 1.79 1.63 1.92-5.17 1.66-15.76 1.51-14.84-1.85-14.84-1.85a46 46 0 0 0 -4.22-3.69c-.37 0-.78.6-.61.72a29.31 29.31 0 0 1 2.86 2.6c-.16 0-3.27-.92-4.32-1.1a7.6 7.6 0 0 1 -2.26-.61c0-.13 6-14.45 6.27-14.41s4.29 10.07 4.29 10.07a2.77 2.77 0 0 0 1.2 2.89 3.9 3.9 0 0 0 3.49-.25l15.51.08c.12 0 2.65 1.27 3.69-.68s.44-2.67.44-2.67 4-11.27 3.76-11.52a5.63 5.63 0 0 0 -1.76-.35s-2.75 10.32-3 10.33-1.26-.3-1.68-.34a3.18 3.18 0 0 0 -.7 0l-5.48-7.62s1.1-6-3.28-6-3.14 6-3.14 6-1-.06-2 1.51-3.39 6.29-3.39 6.29a3.77 3.77 0 0 0 -1.45.2c-.66.27-1.07.49-1.07.49s-3.54-8.29-3.46-8.46.82-2.15 1.2-2.16 2.93.39 3.93.41 1.16-.11 1.2-.28.27-.8.1-.8-5.11-.57-5.48-.57-1.07 2.14-1.36 2.23-1.32.25-1.2 0 2.16-5.13 2.29-5.1 7.53 1.07 9.73.64 2.76-1.11 3.07-2.16a31.45 31.45 0 0 0 .25-6.59 19.1 19.1 0 0 0 -6.2-1.31c-2.79-.09-4.67-.13-5.87 1.82s-10.79 27.71-10.96 27.76a3.64 3.64 0 0 1 -.92-.29s4.91-14 6.78-19.26 3.47-11.56 9.47-11.46z" />
|
||||
<path d="m28.76 40.3a14.69 14.69 0 0 0 2.25 0l2.29-.09s-.39 9.67 0 9.75.67.27.66-.06-.19-7.75.06-7.79.75 1.52.75 1.52-.14 6.42 0 6.46.88.27.84.06 0-4.75 0-4.75l1.43 2.26-.79.06s-.08 2 .17 2a4.75 4.75 0 0 0 .71 0c.12 0 .11 1.16.11 1.16s-7.26-.19-9.8-.13-2.74.11-2.74.11a6.75 6.75 0 0 0 -.55-2l-.62-1.28s4.86-7.15 5.23-7.28z" />
|
||||
</g>
|
||||
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#d4d4d4] dark:[stroke-width:0.7px]">
|
||||
<path d="m30.57 41.13c.28-.1.79-.06.84.1s.41 8.41.17 8.63-.88.14-.88 0-.25-8.69-.13-8.73z" />
|
||||
<path d="m28.74 41.26c.21-.09.7-.19.79 0s.55 8.61.38 8.66-.87.18-.87 0-.3-8.66-.3-8.66z" />
|
||||
<path d="m27 44.8c.06-.14.87-.4.87-.23a52.12 52.12 0 0 1 .21 5.25 3 3 0 0 1 -.79.06s-.38-4.88-.29-5.08z" />
|
||||
<path d="m25.25 46.8c0-.17.57-.64.65-.51a14 14 0 0 1 .26 3.45 4 4 0 0 1 -.79.14s-.16-2.88-.12-3.08z" />
|
||||
<path d="m41.23 35.62c.19-.1 2.79-.16 4.58-.37a11 11 0 0 1 1.78-.25c.21 0 .48.87.36 1a17.41 17.41 0 0 1 -4.32.69c-1.46 0-2.29 0-2.29 0s-.48-.9-.11-1.07z" />
|
||||
<path d="m42 38.39c.34-.09 1.41 1.51 1.41 1.51s-.19.79-.4.63a9.86 9.86 0 0 1 -1.5-1.63c-.08-.21.29-.46.49-.51z" />
|
||||
<path d="m40.41 39.68c.29-.1 2.3 1.78 2.22 2s-.15.63-.28.59a19.84 19.84 0 0 1 -2.43-2.11c-.04-.22.37-.44.49-.48z" />
|
||||
<path d="m39.41 41.16s2.55 2 2.47 2.32a2.36 2.36 0 0 1 -.36.67s-2.93-2.38-2.81-2.6.53-.26.7-.39z" />
|
||||
<path d="m14.45 48.4c.2.07 2.81 2.31 2.82 2.72s-.11.63-.24.63a27 27 0 0 1 -2.93-2.38c-.05-.17.14-1.05.35-.97z" />
|
||||
<path d="m13.67 50.5s3.83 3.16 3.83 3.37-.11.67-.23.67a35.1 35.1 0 0 1 -4.08-3.07c-.04-.25.48-.97.48-.97z" />
|
||||
<path d="m16.27 17c-.17-.24.44-2.3 1.57-3.91s2.19-2.52 2.65-2.4.77.61.6.82a7.05 7.05 0 0 0 -2.72 2.86c-.79 1.85-.55 3-.8 3.06a1.86 1.86 0 0 1 -1.3-.43z" />
|
||||
<path d="m44.85 13.23a2.79 2.79 0 0 1 1.6.54c.09.25.27.83-.11.76a10.8 10.8 0 0 1 -1.43-.53c-.17-.06-.31-.72-.06-.77z" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client"
|
||||
|
||||
export function SidebarNameLogo() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 192.756 192.756"
|
||||
className="fill-[#333333] dark:fill-[#e5e7eb] w-full h-auto"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g fill-rule="evenodd" clip-rule="evenodd">
|
||||
<path d="M5.669 81.215v12.65h37.003c4.301 0 9.796-3.977 9.796-10.556 0-2.646 1.012-4.372-2.098-7.872l-4.733-5.608c-2.712-2.53.324-2.53 2.602-2.53h15.623v26.566h12.39V67.299h16.699V55.922H38.877c-6.579 0-9.796 6.317-9.615 9.606.182 3.289.787 7.427 6.254 12.398 4.987 4.533-2.469 3.289-3.218 3.289H5.669zM120.348 55.922H100.36L89.155 93.866h12.47l2.023-5.313h13.156l1.953 5.313h12.215l-10.624-37.944zm-13.916 23.522l4.301-13.916 4.049 13.916h-8.35zM170.443 81.215c-4.807 0-4.807-1.771-4.807-1.771 4.119 0 7.771-6.001 7.771-12.145s-6-11.377-10.809-11.377h-26.891v37.944h13.664v-12.65s5.818 6.831 8.854 9.614c3.037 2.783 3.289 3.036 7.41 3.036h21.449v-12.65c.002-.001-11.834-.001-16.641-.001zm-12.398-8.855h-8.672v-6.832h8.672c3.976 0 4.664 6.832 0 6.832zM5.669 98.672h13.979l3.542 12.652 3.289-12.652h14.675l3.795 12.652 3.796-12.652h12.144l-11.133 37.953H38.624l-4.878-17.965-5.496 17.965H16.865L5.669 98.672zM89.578 98.891H69.59l-11.204 37.943h12.469l2.024-5.312h13.157l1.953 5.312h12.216L89.578 98.891zm-13.915 23.521l4.301-13.916 4.048 13.916h-8.349zM170.695 110.059c-2.275 0-4.756.266-2.043 2.795l4.734 5.609c3.109 3.5 3.059 4.959 3.059 7.607 0 6.578-6.508 10.555-10.809 10.555l-29.896.201c-4.119 0-4.371-.252-7.408-3.035-3.035-2.783-8.855-9.615-8.855-9.615v12.65h-13.662V98.883h26.891c4.807 0 10.809 5.234 10.809 11.377 0 6.145-3.652 12.145-7.773 12.145 0 0 1.812 1.822 4.848 1.822 3.037 0 14.727.012 14.727.012.748 0 8.203 1.244 3.217-3.289-5.467-4.971-6.072-9.107-6.254-12.396s2.662-9.881 9.238-9.881h25.57v11.387h-16.393v-.001zm-42.545 5.261h-8.674v-6.832h8.674c3.977 0 4.664 6.832 0 6.832z" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
MessageSquare,
|
||||
Bot,
|
||||
Facebook,
|
||||
Calendar,
|
||||
} from "lucide-react"
|
||||
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
@@ -29,6 +30,7 @@ import { FacebookAccountsDialog } from "@/components/settings/facebook-accounts-
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/leads", label: "Leads", icon: Users },
|
||||
{ href: "/calendar", label: "Calendar", icon: Calendar },
|
||||
{ href: "/chats", label: "Chats", icon: MessageSquare },
|
||||
{ href: "/ai-assistant", label: "AI Assistant", icon: Bot, roles: ["sales", "admin", "super_admin"] },
|
||||
{ href: "/users", label: "Users", icon: Building2 },
|
||||
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
CheckCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { CrmIcon } from "./crm-icon";
|
||||
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
|
||||
import { useWebsiteTheme } from "@/providers/website-theme-provider";
|
||||
|
||||
interface TopbarProps {
|
||||
onMenuClick: () => void;
|
||||
@@ -40,6 +43,7 @@ interface TopbarProps {
|
||||
export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { websiteTheme } = useWebsiteTheme();
|
||||
const { user, logout } = useUser();
|
||||
const { notifications, unreadCount, markAsRead, markAllAsRead, dismiss } =
|
||||
useNotifications();
|
||||
@@ -70,17 +74,10 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-white dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden">
|
||||
{/* Red/Blue diagonal split accent */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-[6px] bg-gradient-to-b from-[#CC0000] via-[#0033CC] to-[#CC0000] dark:from-[#FF1111] dark:via-[#1144FF] dark:to-[#FF1111]" />
|
||||
|
||||
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-card dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 mr-2 shrink-0">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="14" cy="14" r="13" fill="#CC0000" />
|
||||
<circle cx="14" cy="14" r="8" fill="#0033CC" />
|
||||
<path d="M14 6 L16 12 L22 12 L17 15 L19 21 L14 17 L9 21 L11 15 L6 12 L12 12 Z" fill="white" />
|
||||
</svg>
|
||||
{websiteTheme === "spidey" ? <CaptainAmericaShield /> : <CrmIcon />}
|
||||
<span className="text-[#CC0000] dark:text-[#FF1111] font-bold text-sm tracking-wide">
|
||||
CRM
|
||||
</span>
|
||||
@@ -116,17 +113,6 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
<Search className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Report a Bug */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setBugModalOpen(true)}
|
||||
className="text-muted-foreground"
|
||||
title="Report a Bug"
|
||||
>
|
||||
<Bug className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -145,6 +131,17 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Report a Bug */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setBugModalOpen(true)}
|
||||
className="text-muted-foreground"
|
||||
title="Report a Bug"
|
||||
>
|
||||
<Bug className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Notifications */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Sun, Moon, Monitor, Sparkles, Shield } from "lucide-react"
|
||||
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||
import { Sun, Moon, Monitor, Check } from "lucide-react"
|
||||
|
||||
const COLOR_THEME_KEY = "crm-color-theme"
|
||||
|
||||
@@ -30,6 +30,11 @@ const themeOptions = [
|
||||
{ value: "ruby", label: "Ruby", color: "bg-red-600", ring: "ring-red-600" },
|
||||
]
|
||||
|
||||
const backgroundOptions = [
|
||||
{ value: "spidey", label: "Spidey", icon: Shield, color: "bg-red-600", ring: "ring-red-600", desc: "Dark theme with red accents" },
|
||||
{ value: "starforce", label: "StarForce", icon: Sparkles, color: "bg-red-700", ring: "ring-red-700", desc: "Cosmic dark with starfield & green glow" },
|
||||
]
|
||||
|
||||
function getStoredColorTheme(): string {
|
||||
if (typeof window === "undefined") return "default"
|
||||
return localStorage.getItem(COLOR_THEME_KEY) || "default"
|
||||
@@ -45,14 +50,6 @@ function applyColorTheme(theme: string) {
|
||||
localStorage.setItem(COLOR_THEME_KEY, theme)
|
||||
}
|
||||
|
||||
const websiteThemes = [
|
||||
{
|
||||
id: "spidey",
|
||||
name: "Spidey",
|
||||
description: "Current website theme",
|
||||
},
|
||||
]
|
||||
|
||||
export function ThemeSettings() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { websiteTheme, setWebsiteTheme } = useWebsiteTheme()
|
||||
@@ -132,66 +129,31 @@ export function ThemeSettings() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Website Themes</CardTitle>
|
||||
<CardTitle>Color Theme for Background</CardTitle>
|
||||
<CardDescription>
|
||||
Select your website's visual theme.
|
||||
Choose the website background theme.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{websiteThemes.map((wt) => {
|
||||
const isActive = websiteTheme === wt.id
|
||||
return (
|
||||
<button
|
||||
key={wt.id}
|
||||
type="button"
|
||||
onClick={() => setWebsiteTheme(wt.id)}
|
||||
<RadioGroup value={websiteTheme} onValueChange={setWebsiteTheme} className="grid grid-cols-2 gap-4">
|
||||
{backgroundOptions.map(({ value, label, icon: Icon, color, ring, desc }) => (
|
||||
<div key={value}>
|
||||
<RadioGroupItem value={value} id={`bg-${value}`} className="peer sr-only" />
|
||||
<Label
|
||||
htmlFor={`bg-${value}`}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-start gap-3 rounded-xl border-2 p-4 text-left transition-all",
|
||||
"hover:bg-accent cursor-pointer",
|
||||
isActive
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-border hover:border-muted-foreground/30"
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all",
|
||||
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<span className="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex h-20 w-full items-center justify-center overflow-hidden rounded-lg border border-border bg-background">
|
||||
<div className="flex h-full w-full">
|
||||
<div className="flex w-1/3 flex-col gap-0.5 bg-[#0a0a0f] p-1.5">
|
||||
<div className="h-1 w-full rounded-sm bg-[#1a1a24]" />
|
||||
<div className="h-1 w-2/3 rounded-sm bg-[#1a1a24]" />
|
||||
<div className="mt-auto h-1.5 w-full rounded-sm bg-[#1a1a24]" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex h-5 items-center gap-1 bg-[#CC0000] px-1.5">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center bg-[#141414]">
|
||||
<div className="h-2 w-2 rounded-full bg-[#CC0000]/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{wt.name}</span>
|
||||
{isActive && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Icon className="h-6 w-6" />
|
||||
<div className={cn("h-8 w-8 rounded-full", color, ring, "ring-2 ring-offset-2 ring-offset-background")} />
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="text-xs text-muted-foreground">{desc}</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
export function CaptainAmericaShield() {
|
||||
return (
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="shield-clip">
|
||||
<polygon points="50,95 5,60 5,30 50,5 95,30 95,60" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#shield-clip)">
|
||||
<circle cx="50" cy="45" r="45" fill="#c62828" />
|
||||
<circle cx="50" cy="45" r="35" fill="#efefef" />
|
||||
<circle cx="50" cy="45" r="25" fill="#c62828" />
|
||||
<circle cx="50" cy="45" r="15" fill="#1565c0" />
|
||||
<polygon points="50,32 56,47 72,48 60,58 64,75 50,64 36,75 40,58 28,48 44,47" fill="#efefef" />
|
||||
</g>
|
||||
<polygon
|
||||
points="50,95 5,60 5,30 50,5 95,30 95,60"
|
||||
fill="none"
|
||||
stroke="#c62828"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user