| import { useState, useRef, useCallback } from "react"; |
|
|
| |
| |
| |
| const THEMES = { |
| dark: { |
| name: "dark", |
| bg: "#070d14", |
| surface: "#0f1923", |
| surfaceAlt: "#0a1520", |
| border: "#1e2d3d", |
| borderFocus: "#3b82f6", |
| text: "#e2e8f0", |
| textMuted: "#4a6080", |
| textSub: "#94a3b8", |
| textBody: "#cbd5e1", |
| inputBg: "#0a1520", |
| uploadBg: "#0a1520", |
| uploadHover: "#1e2d3d", |
| scrollTrack: "#0f1923", |
| scrollThumb: "#1e3a5f", |
| infoBg: "#0a1520", |
| emptyColor: "#1e3a5f", |
| cardBg: "#0f1923", |
| barTrack: "#1e2d3d", |
| btnDisabled: "#1e2d3d", |
| btnDisabledTxt:"#4a6080", |
| headerShadow: "none", |
| cardShadow: "none", |
| }, |
| light: { |
| name: "light", |
| bg: "#f0f4f8", |
| surface: "#ffffff", |
| surfaceAlt: "#f8fafc", |
| border: "#d1dde9", |
| borderFocus: "#2563eb", |
| text: "#0f172a", |
| textMuted: "#64748b", |
| textSub: "#475569", |
| textBody: "#334155", |
| inputBg: "#ffffff", |
| uploadBg: "#f8fafc", |
| uploadHover: "#e2eaf4", |
| scrollTrack: "#e2e8f0", |
| scrollThumb: "#94a3b8", |
| infoBg: "#f1f5f9", |
| emptyColor: "#94a3b8", |
| cardBg: "#ffffff", |
| barTrack: "#e2e8f0", |
| btnDisabled: "#e2e8f0", |
| btnDisabledTxt:"#94a3b8", |
| headerShadow: "0 1px 6px rgba(0,0,0,0.07)", |
| cardShadow: "0 1px 4px rgba(0,0,0,0.06)", |
| }, |
| }; |
|
|
| |
| |
| |
| const ROUGE_L = (hyp, ref) => { |
| if (!hyp || !ref) return 0; |
| const hypW = hyp.toLowerCase().split(/\s+/); |
| const refW = ref.toLowerCase().split(/\s+/); |
| const m = hypW.length, n = refW.length; |
| const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); |
| for (let i = 1; i <= m; i++) |
| for (let j = 1; j <= n; j++) |
| dp[i][j] = hypW[i-1] === refW[j-1] |
| ? dp[i-1][j-1] + 1 |
| : Math.max(dp[i-1][j], dp[i][j-1]); |
| const lcs = dp[m][n]; |
| const prec = lcs / m, rec = lcs / n; |
| if (prec + rec === 0) return 0; |
| return ((2 * prec * rec) / (prec + rec)).toFixed(4); |
| }; |
|
|
| |
| |
| |
| const ThemeToggle = ({ theme, onToggle }) => { |
| const isDark = theme.name === "dark"; |
| return ( |
| <button |
| onClick={onToggle} |
| title={`Switch to ${isDark ? "light" : "dark"} mode`} |
| style={{ |
| display: "flex", alignItems: "center", gap: 9, |
| background: isDark ? "#1a2d42" : "#e8f0fa", |
| border: `1px solid ${isDark ? "#2d4a6a" : "#c4d4e8"}`, |
| borderRadius: 24, padding: "6px 14px 6px 8px", |
| cursor: "pointer", color: theme.text, |
| fontSize: 12, fontWeight: 600, letterSpacing: 0.4, |
| transition: "background .3s, border-color .3s", |
| }} |
| > |
| {/* Toggle track */} |
| <div style={{ |
| width: 42, height: 23, borderRadius: 12, |
| background: isDark ? "#1d4ed8" : "#f59e0b", |
| position: "relative", flexShrink: 0, |
| transition: "background .35s ease", |
| }}> |
| {/* Thumb */} |
| <div style={{ |
| position: "absolute", |
| top: "50%", transform: "translateY(-50%)", |
| left: isDark ? 22 : 3, |
| width: 17, height: 17, borderRadius: "50%", |
| background: "#fff", |
| boxShadow: "0 1px 5px rgba(0,0,0,0.25)", |
| display: "grid", placeItems: "center", |
| fontSize: 9, |
| transition: "left .28s ease", |
| }}> |
| {isDark ? "π" : "βοΈ"} |
| </div> |
| </div> |
| <span style={{ color: theme.textMuted, userSelect: "none" }}> |
| {isDark ? "Dark" : "Light"} |
| </span> |
| </button> |
| ); |
| }; |
|
|
| |
| |
| |
| const ScoreBadge = ({ score }) => { |
| const pct = parseFloat(score) * 100; |
| const color = pct >= 60 ? "#22c55e" : pct >= 35 ? "#f59e0b" : "#ef4444"; |
| return ( |
| <span style={{ |
| background: color + "22", color, |
| border: `1px solid ${color}55`, |
| borderRadius: 6, padding: "2px 10px", |
| fontFamily: "monospace", fontWeight: 700, fontSize: 13, |
| }}> |
| ROUGE-L: {pct.toFixed(1)}% |
| </span> |
| ); |
| }; |
|
|
| |
| |
| |
| const OutputCard = ({ title, icon, content, badge, accent, loading, theme }) => ( |
| <div style={{ |
| background: theme.cardBg, |
| border: `1px solid ${accent}33`, |
| borderRadius: 14, padding: 20, |
| position: "relative", overflow: "hidden", |
| boxShadow: theme.cardShadow, |
| transition: "background .3s, box-shadow .3s", |
| }}> |
| {/* Accent top bar */} |
| <div style={{ position: "absolute", top: 0, left: 0, right: 0, height: 3, background: accent }} /> |
| |
| {/* Card header */} |
| <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}> |
| <span style={{ fontSize: 18 }}>{icon}</span> |
| <span style={{ |
| fontFamily: "'Courier New', monospace", |
| fontSize: 11, fontWeight: 700, color: accent, |
| textTransform: "uppercase", letterSpacing: 2, |
| }}>{title}</span> |
| {badge && <div style={{ marginLeft: "auto" }}>{badge}</div>} |
| </div> |
| |
| {/* Body */} |
| {loading ? ( |
| <div style={{ display: "flex", gap: 6, alignItems: "center", color: theme.textMuted, fontSize: 13 }}> |
| {[0, 0.2, 0.4].map((d, i) => ( |
| <span key={i} style={{ animation: `pulse 1s infinite ${d}s` }}>β</span> |
| ))} |
| <span style={{ marginLeft: 8 }}>Generatingβ¦</span> |
| </div> |
| ) : content ? ( |
| <p style={{ margin: 0, fontSize: 14, lineHeight: 1.78, color: theme.textBody, fontFamily: "Georgia, serif" }}> |
| {content} |
| </p> |
| ) : ( |
| <p style={{ margin: 0, fontSize: 13, color: theme.textMuted, fontStyle: "italic" }}> |
| Awaiting input⦠|
| </p> |
| )} |
| </div> |
| ); |
|
|
| |
| |
| |
| export default function App() { |
| const [themeName, setThemeName] = useState("dark"); |
| const theme = THEMES[themeName]; |
| const toggleTheme = () => setThemeName(t => t === "dark" ? "light" : "dark"); |
|
|
| const [image, setImage] = useState(null); |
| const [imageFile, setImageFile] = useState(null); |
| const [groundTruth, setGroundTruth] = useState(""); |
| const [sftOutput, setSftOutput] = useState(""); |
| const [rewardOutput, setRewardOutput] = useState(""); |
| const [ppoOutput, setPpoOutput] = useState(""); |
| const [rewardScore, setRewardScore] = useState(null); |
| const [loading, setLoading] = useState(false); |
| const [dragging, setDragging] = useState(false); |
| const fileRef = useRef(); |
|
|
| const runInference = async () => { |
| if (!image || !imageFile) return; |
| setLoading(true); |
| setSftOutput(""); setRewardOutput(""); setPpoOutput(""); setRewardScore(null); |
|
|
| |
| const BASE = ""; |
|
|
| try { |
| |
| const sftForm = new FormData(); |
| sftForm.append("file", imageFile); |
| const sftRes = await fetch(`${BASE}/sft`, { method: "POST", body: sftForm }); |
| const sftData = await sftRes.json(); |
| setSftOutput(sftData.report); |
|
|
| |
| const rmForm = new FormData(); |
| rmForm.append("file", imageFile); |
| const rmRes = await fetch(`${BASE}/reward`, { method: "POST", body: rmForm }); |
| const rmData = await rmRes.json(); |
| setRewardOutput(rmData.feedback); |
| setRewardScore(rmData.score.toFixed(2)); |
|
|
| |
| const ppoForm = new FormData(); |
| ppoForm.append("file", imageFile); |
| const ppoRes = await fetch(`${BASE}/ppo`, { method: "POST", body: ppoForm }); |
| const ppoData = await ppoRes.json(); |
| setPpoOutput(ppoData.report); |
|
|
| } catch (err) { |
| console.error("Inference error:", err); |
| setSftOutput("β οΈ Could not connect to server. Please check your connection."); |
| } |
|
|
| setLoading(false); |
| }; |
|
|
| const handleFile = (file) => { |
| if (!file || !file.type.startsWith("image/")) return; |
| setImageFile(file); |
| const reader = new FileReader(); |
| reader.onload = e => setImage(e.target.result); |
| reader.readAsDataURL(file); |
| }; |
|
|
| const onDrop = useCallback((e) => { |
| e.preventDefault(); setDragging(false); |
| handleFile(e.dataTransfer.files[0]); |
| }, []); |
|
|
| const clearAll = () => { |
| setImage(null); setImageFile(null); |
| setSftOutput(""); setRewardOutput(""); setPpoOutput(""); setRewardScore(null); |
| if (fileRef.current) fileRef.current.value = ""; |
| }; |
|
|
| const rougeSFT = groundTruth && sftOutput ? ROUGE_L(sftOutput, groundTruth) : null; |
| const rougePPO = groundTruth && ppoOutput ? ROUGE_L(ppoOutput, groundTruth) : null; |
|
|
| return ( |
| <div style={{ |
| minHeight: "100vh", |
| background: theme.bg, |
| color: theme.text, |
| fontFamily: "system-ui, -apple-system, sans-serif", |
| transition: "background .3s ease, color .3s ease", |
| }}> |
| |
| {/* ββ GLOBAL STYLES ββ */} |
| <style>{` |
| @keyframes pulse { 0%,100%{opacity:.3} 50%{opacity:1} } |
| @keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} } |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| ::-webkit-scrollbar { width: 6px; } |
| ::-webkit-scrollbar-track { background: ${theme.scrollTrack}; } |
| ::-webkit-scrollbar-thumb { background: ${theme.scrollThumb}; border-radius: 3px; } |
| textarea { font-family: system-ui, sans-serif; } |
| textarea:focus { |
| outline: none; |
| border-color: ${theme.borderFocus} !important; |
| box-shadow: 0 0 0 3px ${theme.borderFocus}22; |
| } |
| button { transition: all .22s ease !important; } |
| button:not(:disabled):hover { filter: brightness(1.1); transform: translateY(-1px); } |
| button:not(:disabled):active { transform: translateY(0px); filter: brightness(0.97); } |
| `}</style> |
| |
| {/* βββββββββββββββββββββββββββββββββββ |
| HEADER |
| βββββββββββββββββββββββββββββββββββ */} |
| <header style={{ |
| borderBottom: `2px solid ${theme.border}`, |
| padding: "20px 32px", |
| display: "flex", alignItems: "center", gap: 18, |
| background: `linear-gradient(135deg, ${theme.surface} 0%, ${theme.surfaceAlt} 100%)`, |
| boxShadow: "0 4px 20px rgba(0,0,0,0.1)", |
| transition: "background .3s, border-color .3s, box-shadow .3s", |
| position: "sticky", top: 0, zIndex: 100, |
| backdropFilter: "blur(10px)", |
| WebkitBackdropFilter: "blur(10px)", |
| }}> |
| {/* Logo mark */} |
| <div style={{ |
| width: 52, height: 52, flexShrink: 0, |
| background: "linear-gradient(135deg,#3b82f6,#06b6d4)", |
| borderRadius: 14, display: "grid", placeItems: "center", |
| fontSize: 24, boxShadow: "0 4px 16px rgba(59,130,246,0.4)", |
| border: "2px solid rgba(255,255,255,0.2)", |
| }}>π«</div> |
| |
| {/* Titles */} |
| <div> |
| <div style={{ |
| fontWeight: 900, |
| fontSize: 22, |
| letterSpacing: -0.8, |
| color: theme.text, |
| textShadow: theme.name === "dark" ? "0 1px 2px rgba(0,0,0,0.3)" : "none", |
| marginBottom: 4 |
| }}> |
| BioStack |
| </div> |
| <div style={{ |
| fontSize: 12, |
| color: theme.textMuted, |
| letterSpacing: 1.2, |
| textTransform: "uppercase", |
| fontWeight: 600, |
| opacity: 0.9 |
| }}> |
| RLHF Based Medical Report Generation |
| </div> |
| </div> |
| |
| <div style={{ flex: 1 }} /> |
| |
| {/* π/βοΈ Toggle */} |
| <ThemeToggle theme={theme} onToggle={toggleTheme} /> |
| </header> |
| |
| {/* βββββββββββββββββββββββββββββββββββ |
| MAIN GRID |
| βββββββββββββββββββββββββββββββββββ */} |
| <div style={{ |
| display: "grid", |
| gridTemplateColumns: "370px 1fr", |
| height: "calc(100vh - 155px)", // Further decreased for optimal fit |
| overflow: "hidden", // Prevent any overflow |
| }}> |
| |
| {/* ββ LEFT PANEL ββ */} |
| <aside style={{ |
| borderRight: `1px solid ${theme.border}`, |
| padding: "15px 18px", // Reduced padding |
| display: "flex", flexDirection: "column", gap: 14, // Reduced gap |
| background: theme.surface, |
| overflowY: "auto", |
| transition: "background .3s, border-color .3s", |
| height: "100%", // Ensure full height |
| }}> |
| |
| <div style={{ fontSize: 10, color: theme.textMuted, textTransform: "uppercase", letterSpacing: 2, fontWeight: 700 }}> |
| π€ Input |
| </div> |
| |
| {/* Upload zone */} |
| <div> |
| <label style={{ |
| fontSize: 11, color: theme.textMuted, |
| textTransform: "uppercase", letterSpacing: 1.5, |
| fontWeight: 700, display: "block", marginBottom: 8, |
| }}>Chest X-Ray Image</label> |
| |
| <div |
| onClick={() => fileRef.current.click()} |
| onDragOver={e => { e.preventDefault(); setDragging(true); }} |
| onDragLeave={() => setDragging(false)} |
| onDrop={onDrop} |
| style={{ |
| border: `2px dashed ${dragging ? "#3b82f6" : image ? "#22c55e66" : theme.border}`, |
| borderRadius: 14, minHeight: 190, |
| display: "flex", flexDirection: "column", |
| alignItems: "center", justifyContent: "center", |
| cursor: "pointer", |
| background: dragging ? theme.uploadHover : theme.uploadBg, |
| transition: "all .22s", overflow: "hidden", |
| }} |
| > |
| {image ? ( |
| <img src={image} alt="X-Ray" style={{ |
| width: "100%", height: "100%", |
| objectFit: "contain", maxHeight: 240, borderRadius: 12, |
| }} /> |
| ) : ( |
| <> |
| <div style={{ fontSize: 40, marginBottom: 10, opacity: 0.65 }}>π©»</div> |
| <div style={{ fontSize: 13, color: theme.textMuted, textAlign: "center", lineHeight: 1.65 }}> |
| Click or drag & drop<br /> |
| <span style={{ fontSize: 11, color: theme.emptyColor }}>PNG, JPG, DICOM supported</span> |
| </div> |
| </> |
| )} |
| </div> |
| <input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} |
| onChange={e => handleFile(e.target.files[0])} /> |
| </div> |
| |
| {/* Clear button */} |
| {image && ( |
| <button onClick={clearAll} style={{ |
| background: "transparent", |
| border: `1px solid ${theme.border}`, |
| color: theme.textMuted, |
| borderRadius: 8, padding: "5px 14px", |
| cursor: "pointer", fontSize: 12, alignSelf: "flex-start", |
| }}> |
| β Clear Image |
| </button> |
| )} |
| |
| {/* Divider */} |
| <div style={{ height: 1, background: theme.border }} /> |
| |
| {/* Ground truth */} |
| <div> |
| <label style={{ |
| fontSize: 11, color: theme.textMuted, |
| textTransform: "uppercase", letterSpacing: 1.5, |
| fontWeight: 700, display: "block", marginBottom: 8, |
| }}> |
| π Ground Truth{" "} |
| <span style={{ color: theme.emptyColor, textTransform: "none", letterSpacing: 0, fontWeight: 400, fontSize: 11 }}> |
| (optional) |
| </span> |
| </label> |
| <textarea |
| value={groundTruth} |
| onChange={e => setGroundTruth(e.target.value)} |
| placeholder="Paste the radiologist ground truth report here to see ROUGE-L scoresβ¦" |
| rows={5} |
| style={{ |
| width: "100%", |
| background: theme.inputBg, |
| border: `1px solid ${theme.border}`, |
| borderRadius: 10, |
| color: theme.text, |
| padding: "10px 12px", |
| fontSize: 13, resize: "vertical", lineHeight: 1.65, |
| transition: "border .2s, box-shadow .2s, background .3s, color .3s", |
| }} |
| /> |
| </div> |
| |
| {/* Run button */} |
| <button |
| onClick={runInference} |
| disabled={!image || loading} |
| style={{ |
| background: image && !loading |
| ? "linear-gradient(135deg,#1d4ed8,#0891b2)" |
| : theme.btnDisabled, |
| color: image && !loading ? "#fff" : theme.btnDisabledTxt, |
| border: "none", borderRadius: 12, |
| padding: "13px 20px", |
| fontSize: 14, fontWeight: 700, |
| cursor: image && !loading ? "pointer" : "not-allowed", |
| letterSpacing: 0.5, width: "100%", |
| boxShadow: image && !loading ? "0 4px 16px rgba(29,78,216,0.38)" : "none", |
| }}> |
| {loading ? "β³ Running Pipelineβ¦" : "βΆ Run RLHF Pipeline"} |
| </button> |
| </aside> |
| |
| {/* ββ RIGHT PANEL ββ */} |
| <main style={{ |
| padding: "18px 22px", // Reduced padding |
| display: "flex", flexDirection: "column", gap: 16, // Reduced gap |
| overflowY: "auto", |
| background: theme.bg, |
| transition: "background .3s", |
| height: "100%", // Ensure full height |
| }}> |
| |
| <div style={{ fontSize: 10, color: theme.textMuted, textTransform: "uppercase", letterSpacing: 2, fontWeight: 700 }}> |
| π Pipeline Outputs |
| </div> |
| |
| {/* SFT */} |
| <div style={{ animation: sftOutput ? "fadeIn .4s ease" : "none" }}> |
| <OutputCard theme={theme} title="SFT Model Output β Original" icon="π§ " accent="#3b82f6" |
| content={sftOutput} loading={loading && !sftOutput} |
| badge={rougeSFT !== null && <ScoreBadge score={rougeSFT} />} |
| /> |
| </div> |
| |
| {/* Reward */} |
| <div style={{ animation: rewardOutput ? "fadeIn .4s ease" : "none" }}> |
| <OutputCard theme={theme} title="Reward Model Output" icon="βοΈ" accent="#f59e0b" |
| content={rewardOutput} loading={loading && sftOutput && !rewardOutput} |
| badge={rewardScore && ( |
| <span style={{ |
| background: "#f59e0b22", color: "#f59e0b", |
| border: "1px solid #f59e0b55", |
| borderRadius: 6, padding: "2px 10px", |
| fontFamily: "monospace", fontWeight: 700, fontSize: 13, |
| }}> |
| Reward: {rewardScore} |
| </span> |
| )} |
| /> |
| </div> |
| |
| {/* PPO */} |
| <div style={{ animation: ppoOutput ? "fadeIn .4s ease" : "none" }}> |
| <OutputCard theme={theme} title="PPO Final Model Output" icon="π―" accent="#22c55e" |
| content={ppoOutput} loading={loading && rewardOutput && !ppoOutput} |
| badge={rougePPO !== null && <ScoreBadge score={rougePPO} />} |
| /> |
| </div> |
| |
| {/* ROUGE-L comparison */} |
| {groundTruth && sftOutput && ppoOutput && ( |
| <div style={{ |
| background: theme.surface, |
| border: `1px solid ${theme.border}`, |
| borderRadius: 14, padding: 20, |
| animation: "fadeIn .5s ease", |
| boxShadow: theme.cardShadow, |
| transition: "background .3s, border-color .3s", |
| }}> |
| <div style={{ fontSize: 10, color: theme.textMuted, textTransform: "uppercase", letterSpacing: 2, fontWeight: 700, marginBottom: 16 }}> |
| π ROUGE-L Comparison vs Ground Truth |
| </div> |
| <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}> |
| {[ |
| { label: "SFT (Original)", score: rougeSFT, color: "#3b82f6" }, |
| { label: "PPO (Final)", score: rougePPO, color: "#22c55e" }, |
| ].map(({ label, score, color }) => ( |
| <div key={label} style={{ |
| background: theme.surfaceAlt, borderRadius: 10, padding: 16, |
| border: `1px solid ${color}33`, |
| transition: "background .3s", |
| }}> |
| <div style={{ fontSize: 12, color: theme.textMuted, marginBottom: 8 }}>{label}</div> |
| <div style={{ fontSize: 28, fontWeight: 800, color, fontFamily: "monospace" }}> |
| {(parseFloat(score) * 100).toFixed(1)}% |
| </div> |
| <div style={{ marginTop: 10, height: 5, background: theme.barTrack, borderRadius: 3 }}> |
| <div style={{ |
| width: `${parseFloat(score) * 100}%`, height: "100%", |
| background: color, borderRadius: 3, |
| transition: "width 1.2s ease", |
| }} /> |
| </div> |
| {parseFloat(rougePPO) > parseFloat(rougeSFT) && label.includes("PPO") && ( |
| <div style={{ fontSize: 11, color: "#22c55e", marginTop: 7, fontWeight: 600 }}> |
| β² +{((parseFloat(rougePPO) - parseFloat(rougeSFT)) * 100).toFixed(1)}% improvement |
| </div> |
| )} |
| </div> |
| ))} |
| </div> |
| </div> |
| )} |
| |
| {/* Ground truth reference */} |
| {groundTruth && ( |
| <div style={{ |
| background: theme.surface, |
| border: `1px solid ${theme.border}`, |
| borderRadius: 14, padding: 20, |
| boxShadow: theme.cardShadow, |
| transition: "background .3s, border-color .3s", |
| }}> |
| <div style={{ fontSize: 10, color: theme.textMuted, textTransform: "uppercase", letterSpacing: 2, fontWeight: 700, marginBottom: 10 }}> |
| π Ground Truth Reference |
| </div> |
| <p style={{ fontSize: 14, lineHeight: 1.78, color: theme.textSub, fontFamily: "Georgia, serif" }}> |
| {groundTruth} |
| </p> |
| </div> |
| )} |
| |
| {/* Empty state */} |
| {!image && !loading && ( |
| <div style={{ |
| flex: 1, display: "flex", flexDirection: "column", |
| alignItems: "center", justifyContent: "center", |
| color: theme.emptyColor, gap: 14, paddingTop: 60, |
| }}> |
| <div style={{ fontSize: 64, opacity: 0.35 }}>π©»</div> |
| <div style={{ fontSize: 14, fontWeight: 600, opacity: 0.45 }}>Upload a chest X-ray to begin</div> |
| <div style={{ fontSize: 12, opacity: 0.3 }}>Results will appear here after running the pipeline</div> |
| </div> |
| )} |
| </main> |
| </div> |
|
|
| { |
| |
| } |
| <footer style={{ |
| borderTop: `2px solid ${theme.border}`, |
| padding: "18px 32px", |
| background: `linear-gradient(135deg, ${theme.surfaceAlt} 0%, ${theme.surface} 100%)`, |
| textAlign: "center", |
| fontSize: 13, |
| color: theme.textMuted, |
| fontWeight: 600, |
| letterSpacing: 0.5, |
| transition: "background .3s, border-color .3s, color .3s", |
| position: "relative", |
| boxShadow: "0 -2px 10px rgba(0,0,0,0.05)", |
| backdropFilter: "blur(8px)", |
| WebkitBackdropFilter: "blur(8px)", |
| }}> |
| <div style={{ |
| display: "flex", |
| alignItems: "center", |
| justifyContent: "center", |
| gap: 8, |
| opacity: 0.8 |
| }}> |
| <span style={{ fontSize: 16 }}>Β©</span> |
| <span>2026 BioStack. All rights reserved.</span> |
| |
| </div> |
| </footer> |
| </div> |
| ); |
| } |