hoshikrana commited on
Commit
001f4b1
·
1 Parent(s): af005a8

chore: add runtime frontend and backend files

Browse files
backend/utils/file_storage.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import mimetypes
3
+ import shutil
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ from backend.core.config import settings
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class StoredFile:
13
+ key: str
14
+ url: str | None
15
+ backend: str
16
+ sha256: str
17
+ size_bytes: int
18
+
19
+
20
+ class FileStorage:
21
+ """Stores original uploads locally or in Cloudflare R2.
22
+
23
+ The analysis queue still works from a temporary local processing file.
24
+ This class keeps a durable copy for audit/history/download flows.
25
+ """
26
+
27
+ @staticmethod
28
+ def save_upload(content: bytes, filename: str, user_id: str | None = None) -> StoredFile:
29
+ digest = hashlib.sha256(content).hexdigest()
30
+ safe_name = Path(filename or "upload.bin").name.replace(" ", "_")
31
+ date_prefix = datetime.now(timezone.utc).strftime("%Y/%m/%d")
32
+ owner = user_id or "anonymous"
33
+ key = f"uploads/{owner}/{date_prefix}/{digest[:16]}_{safe_name}"
34
+
35
+ if settings.STORAGE_BACKEND == "r2":
36
+ return FileStorage._save_r2(content, key, digest)
37
+ return FileStorage._save_local(content, key, digest)
38
+
39
+ @staticmethod
40
+ def _save_local(content: bytes, key: str, digest: str) -> StoredFile:
41
+ target = settings.LOCAL_STORAGE_DIR / key
42
+ target.parent.mkdir(parents=True, exist_ok=True)
43
+ target.write_bytes(content)
44
+ return StoredFile(
45
+ key=key,
46
+ url=str(target),
47
+ backend="local",
48
+ sha256=digest,
49
+ size_bytes=len(content),
50
+ )
51
+
52
+ @staticmethod
53
+ def _save_r2(content: bytes, key: str, digest: str) -> StoredFile:
54
+ try:
55
+ import boto3
56
+ except ImportError as exc:
57
+ raise RuntimeError("boto3 is required when STORAGE_BACKEND=r2") from exc
58
+
59
+ content_type = mimetypes.guess_type(key)[0] or "application/octet-stream"
60
+ client = boto3.client(
61
+ "s3",
62
+ endpoint_url=settings.R2_ENDPOINT_URL,
63
+ aws_access_key_id=settings.R2_ACCESS_KEY_ID,
64
+ aws_secret_access_key=settings.R2_SECRET_ACCESS_KEY,
65
+ region_name="auto",
66
+ )
67
+ client.put_object(
68
+ Bucket=settings.R2_BUCKET_NAME,
69
+ Key=key,
70
+ Body=content,
71
+ ContentType=content_type,
72
+ Metadata={"sha256": digest},
73
+ )
74
+ public_base = settings.R2_PUBLIC_BASE_URL.rstrip("/")
75
+ return StoredFile(
76
+ key=key,
77
+ url=f"{public_base}/{key}" if public_base else None,
78
+ backend="r2",
79
+ sha256=digest,
80
+ size_bytes=len(content),
81
+ )
82
+
83
+ @staticmethod
84
+ def copy_to_temp(stored: StoredFile, temp_path: Path) -> Path:
85
+ temp_path.parent.mkdir(parents=True, exist_ok=True)
86
+
87
+ if stored.backend == "local":
88
+ source = Path(stored.url or "")
89
+ if not source.exists():
90
+ raise FileNotFoundError(f"Local file not found: {source}")
91
+ shutil.copyfile(source, temp_path)
92
+
93
+ elif stored.backend == "r2":
94
+ try:
95
+ import boto3
96
+ except ImportError as exc:
97
+ raise RuntimeError("boto3 is required when STORAGE_BACKEND=r2") from exc
98
+
99
+ client = boto3.client(
100
+ "s3",
101
+ endpoint_url=settings.R2_ENDPOINT_URL,
102
+ aws_access_key_id=settings.R2_ACCESS_KEY_ID,
103
+ aws_secret_access_key=settings.R2_SECRET_ACCESS_KEY,
104
+ region_name="auto",
105
+ )
106
+ client.download_file(settings.R2_BUCKET_NAME, stored.key, str(temp_path))
107
+
108
+ else:
109
+ raise ValueError(f"Unsupported storage backend: {stored.backend}")
110
+
111
+ return temp_path
data/uploads/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+
frontend/app/(auth)/login/page.jsx ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import { useState } from 'react'
3
+ import Link from 'next/link'
4
+ import { useRouter } from 'next/navigation'
5
+ import { Brain, Mail, Lock, ArrowRight, AlertCircle } from 'lucide-react'
6
+ import { useAuth } from '@/lib/auth/AuthContext'
7
+
8
+ export default function LoginPage() {
9
+ const { login } = useAuth()
10
+ const router = useRouter()
11
+ const [email, setEmail] = useState('')
12
+ const [password, setPassword] = useState('')
13
+ const [error, setError] = useState(null)
14
+ const [loading, setLoading] = useState(false)
15
+
16
+ const handleSubmit = async (e) => {
17
+ e.preventDefault()
18
+ setError(null)
19
+ setLoading(true)
20
+ try {
21
+ await login(email, password)
22
+ const intended = sessionStorage.getItem('intendedPath') || '/upload'
23
+ sessionStorage.removeItem('intendedPath')
24
+ router.push(intended)
25
+ } catch (err) {
26
+ setError(err.message || 'Login failed')
27
+ } finally {
28
+ setLoading(false)
29
+ }
30
+ }
31
+
32
+ return (
33
+ <div className="min-h-[85vh] flex items-center justify-center px-4 relative">
34
+ <div className="absolute inset-0 grid-bg opacity-30" />
35
+ <div className="relative z-10 w-full max-w-md">
36
+ <div className="text-center mb-8">
37
+ <div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-teal-400 to-teal-500 flex items-center justify-center mx-auto mb-4 shadow-lg">
38
+ <Brain className="w-7 h-7 text-navy-900" />
39
+ </div>
40
+ <h1 className="text-2xl font-bold text-white">Welcome back</h1>
41
+ <p className="text-gray-400 text-sm mt-1">Sign in to your MedSight AI account</p>
42
+ </div>
43
+
44
+ <div className="glass-card p-8">
45
+ {error && (
46
+ <div className="flex items-center gap-2 px-4 py-3 mb-6 bg-red-500/10 border border-red-500/20 rounded-xl">
47
+ <AlertCircle className="w-4 h-4 text-red-400 shrink-0" />
48
+ <p className="text-sm text-red-400">{error}</p>
49
+ </div>
50
+ )}
51
+
52
+ <form onSubmit={handleSubmit} className="space-y-5">
53
+ <div>
54
+ <label className="block text-sm font-medium text-gray-300 mb-2">Email</label>
55
+ <div className="relative">
56
+ <Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
57
+ <input
58
+ type="email" value={email} onChange={(e) => setEmail(e.target.value)}
59
+ placeholder="you@example.com"
60
+ className="input-field pl-10"
61
+ required
62
+ />
63
+ </div>
64
+ </div>
65
+
66
+ <div>
67
+ <div className="flex justify-between items-center mb-2">
68
+ <label className="text-sm font-medium text-gray-300">Password</label>
69
+ <Link href="/forgot-password" className="text-xs text-teal-400 hover:text-teal-300">Forgot password?</Link>
70
+ </div>
71
+ <div className="relative">
72
+ <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
73
+ <input
74
+ type="password" value={password} onChange={(e) => setPassword(e.target.value)}
75
+ placeholder="••••••••"
76
+ className="input-field pl-10"
77
+ required
78
+ />
79
+ </div>
80
+ </div>
81
+
82
+ <button
83
+ type="submit"
84
+ disabled={loading}
85
+ className="btn-primary w-full flex items-center justify-center gap-2 disabled:opacity-50"
86
+ >
87
+ {loading ? 'Signing in...' : <>Sign In <ArrowRight className="w-4 h-4" /></>}
88
+ </button>
89
+ </form>
90
+
91
+ <div className="mt-6 text-center">
92
+ <p className="text-sm text-gray-500">
93
+ Don&apos;t have an account? <Link href="/register" className="text-teal-400 hover:text-teal-300 font-medium">Sign up</Link>
94
+ </p>
95
+ </div>
96
+
97
+ {process.env.NEXT_PUBLIC_GOOGLE_ENABLED === 'true' && (
98
+ <>
99
+ <div className="relative my-6">
100
+ <div className="absolute inset-0 flex items-center">
101
+ <div className="w-full border-t border-navy-700/50" />
102
+ </div>
103
+ <div className="relative flex justify-center text-sm">
104
+ <span className="px-3 bg-navy-800 text-gray-500">or continue with</span>
105
+ </div>
106
+ </div>
107
+ <button
108
+ type="button"
109
+ onClick={() => {
110
+ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
111
+ window.location.href = `${API_URL}/api/v1/auth/google/login`
112
+ }}
113
+ className="w-full flex items-center justify-center gap-3 px-4 py-2.5 bg-white text-gray-800 rounded-xl font-medium hover:bg-gray-100 transition shadow-sm border border-gray-200"
114
+ >
115
+ <svg className="w-5 h-5" viewBox="0 0 24 24">
116
+ <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
117
+ <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
118
+ <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18A10.97 10.97 0 0 0 1 12c0 1.77.42 3.44 1.18 4.93l3.66-2.84z"/>
119
+ <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
120
+ </svg>
121
+ Sign in with Google
122
+ </button>
123
+ </>
124
+ )}
125
+
126
+ <div className="mt-6 pt-6 border-t border-navy-700/50 text-center">
127
+ <p className="text-xs text-gray-600 mb-3">Need access first?</p>
128
+ <button
129
+ onClick={() => router.push('/register')}
130
+ className="btn-ghost text-sm w-full"
131
+ >
132
+ Create an account
133
+ </button>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ )
139
+ }
frontend/app/(auth)/register/page.jsx ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import { useState } from 'react'
3
+ import Link from 'next/link'
4
+ import { Brain, Mail, Lock, User, ArrowRight, AlertCircle } from 'lucide-react'
5
+
6
+ export default function RegisterPage() {
7
+ const [form, setForm] = useState({ full_name: '', email: '', password: '', confirm: '' })
8
+ const [error, setError] = useState(null)
9
+ const [success, setSuccess] = useState(false)
10
+ const [loading, setLoading] = useState(false)
11
+
12
+ const update = (field) => (e) => setForm(prev => ({ ...prev, [field]: e.target.value }))
13
+
14
+ const handleSubmit = async (e) => {
15
+ e.preventDefault()
16
+ setError(null)
17
+ if (form.password !== form.confirm) {
18
+ setError("Passwords do not match")
19
+ return
20
+ }
21
+ setLoading(true)
22
+ try {
23
+ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
24
+ const res = await fetch(`${API_URL}/api/v1/auth/register`, {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/json' },
27
+ body: JSON.stringify({ full_name: form.full_name, email: form.email, password: form.password })
28
+ })
29
+ if (!res.ok) {
30
+ const data = await res.json()
31
+ throw new Error(data.message || 'Registration failed')
32
+ }
33
+ setSuccess(true)
34
+ } catch (err) {
35
+ setError(err.message)
36
+ } finally {
37
+ setLoading(false)
38
+ }
39
+ }
40
+
41
+ return (
42
+ <div className="min-h-[85vh] flex items-center justify-center px-4 relative">
43
+ <div className="absolute inset-0 grid-bg opacity-30" />
44
+ <div className="relative z-10 w-full max-w-md">
45
+ <div className="text-center mb-8">
46
+ <div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-teal-400 to-teal-500 flex items-center justify-center mx-auto mb-4 shadow-lg">
47
+ <Brain className="w-7 h-7 text-navy-900" />
48
+ </div>
49
+ <h1 className="text-2xl font-bold text-white">Create Account</h1>
50
+ <p className="text-gray-400 text-sm mt-1">Get started with MedSight AI</p>
51
+ </div>
52
+
53
+ <div className="glass-card p-8">
54
+ {success ? (
55
+ <div className="text-center py-4">
56
+ <p className="text-emerald-400 font-semibold mb-2">Account created!</p>
57
+ <p className="text-gray-400 text-sm mb-4">In local development, you can sign in immediately.</p>
58
+ <Link href="/login" className="btn-primary inline-flex items-center gap-2 text-sm">Go to Login</Link>
59
+ </div>
60
+ ) : (
61
+ <>
62
+ {error && (
63
+ <div className="flex items-center gap-2 px-4 py-3 mb-6 bg-red-500/10 border border-red-500/20 rounded-xl">
64
+ <AlertCircle className="w-4 h-4 text-red-400 shrink-0" />
65
+ <p className="text-sm text-red-400">{error}</p>
66
+ </div>
67
+ )}
68
+
69
+ <form onSubmit={handleSubmit} className="space-y-4">
70
+ <div>
71
+ <label className="block text-sm font-medium text-gray-300 mb-2">Full Name</label>
72
+ <div className="relative">
73
+ <User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
74
+ <input type="text" value={form.full_name} onChange={update('full_name')} placeholder="John Doe" className="input-field pl-10" required />
75
+ </div>
76
+ </div>
77
+ <div>
78
+ <label className="block text-sm font-medium text-gray-300 mb-2">Email</label>
79
+ <div className="relative">
80
+ <Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
81
+ <input type="email" value={form.email} onChange={update('email')} placeholder="you@example.com" className="input-field pl-10" required />
82
+ </div>
83
+ </div>
84
+ <div>
85
+ <label className="block text-sm font-medium text-gray-300 mb-2">Password</label>
86
+ <div className="relative">
87
+ <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
88
+ <input type="password" value={form.password} onChange={update('password')} placeholder="••••••••" className="input-field pl-10" required minLength={8} />
89
+ </div>
90
+ </div>
91
+ <div>
92
+ <label className="block text-sm font-medium text-gray-300 mb-2">Confirm Password</label>
93
+ <div className="relative">
94
+ <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
95
+ <input type="password" value={form.confirm} onChange={update('confirm')} placeholder="••••••••" className="input-field pl-10" required />
96
+ </div>
97
+ </div>
98
+ <button type="submit" disabled={loading} className="btn-primary w-full flex items-center justify-center gap-2 disabled:opacity-50">
99
+ {loading ? 'Creating...' : <>Create Account <ArrowRight className="w-4 h-4" /></>}
100
+ </button>
101
+ </form>
102
+
103
+ <div className="mt-6 text-center">
104
+ <p className="text-sm text-gray-500">Already have an account? <Link href="/login" className="text-teal-400 hover:text-teal-300 font-medium">Sign in</Link></p>
105
+ </div>
106
+ </>
107
+ )}
108
+ </div>
109
+ </div>
110
+ </div>
111
+ )
112
+ }
frontend/app/globals.css ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --border: 222.2 47.4% 11.2%;
8
+ --input: 222.2 47.4% 11.2%;
9
+ --ring: 174 100% 41%;
10
+ --glow-teal: 0 0 40px rgba(0, 212, 180, 0.15);
11
+ --glow-teal-strong: 0 0 60px rgba(0, 212, 180, 0.25);
12
+ }
13
+
14
+ * {
15
+ @apply border-border;
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ html {
20
+ scroll-behavior: smooth;
21
+ }
22
+
23
+ body {
24
+ @apply bg-navy-900 text-white;
25
+ font-feature-settings: "rlig" 1, "calt" 1;
26
+ -webkit-font-smoothing: antialiased;
27
+ -moz-osx-font-smoothing: grayscale;
28
+ overflow-x: hidden;
29
+ }
30
+
31
+ /* Premium Scrollbar */
32
+ ::-webkit-scrollbar {
33
+ width: 5px;
34
+ height: 5px;
35
+ }
36
+ ::-webkit-scrollbar-track {
37
+ background: transparent;
38
+ }
39
+ ::-webkit-scrollbar-thumb {
40
+ background: #1E3A6E;
41
+ border-radius: 10px;
42
+ }
43
+ ::-webkit-scrollbar-thumb:hover {
44
+ background: #00D4B4;
45
+ }
46
+
47
+ /* Selection */
48
+ ::selection {
49
+ background: rgba(0, 212, 180, 0.3);
50
+ color: white;
51
+ }
52
+ }
53
+
54
+ @layer components {
55
+ /* Glass Cards */
56
+ .glass-card {
57
+ @apply bg-navy-800/60 backdrop-blur-xl border border-navy-600/50 rounded-2xl;
58
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.05);
59
+ }
60
+
61
+ .glass-card-hover {
62
+ @apply glass-card transition-all duration-300;
63
+ }
64
+ .glass-card-hover:hover {
65
+ @apply border-teal-500/40;
66
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 0 30px rgba(0, 212, 180, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.05);
67
+ transform: translateY(-2px);
68
+ }
69
+
70
+ /* Buttons */
71
+ .btn-primary {
72
+ @apply px-6 py-3 bg-gradient-to-r from-teal-500 to-teal-400 text-navy-900 font-semibold rounded-xl
73
+ hover:from-teal-400 hover:to-teal-300 transition-all duration-300 shadow-lg;
74
+ box-shadow: 0 4px 20px rgba(0, 212, 180, 0.3);
75
+ }
76
+ .btn-primary:hover {
77
+ box-shadow: 0 6px 30px rgba(0, 212, 180, 0.45);
78
+ transform: translateY(-1px);
79
+ }
80
+
81
+ .btn-secondary {
82
+ @apply px-6 py-3 bg-navy-800 text-white font-medium rounded-xl border border-navy-600
83
+ hover:bg-navy-700 hover:border-navy-500 transition-all duration-300;
84
+ }
85
+
86
+ .btn-ghost {
87
+ @apply px-6 py-3 text-gray-400 font-medium rounded-xl
88
+ hover:text-white hover:bg-white/5 transition-all duration-300;
89
+ }
90
+
91
+ /* Section Headers */
92
+ .section-title {
93
+ @apply text-3xl md:text-4xl font-bold text-white tracking-tight;
94
+ }
95
+ .section-subtitle {
96
+ @apply text-lg text-gray-400 max-w-2xl mx-auto leading-relaxed;
97
+ }
98
+
99
+ /* Animated Grid Background */
100
+ .grid-bg {
101
+ background-image:
102
+ linear-gradient(rgba(30, 58, 110, 0.15) 1px, transparent 1px),
103
+ linear-gradient(90deg, rgba(30, 58, 110, 0.15) 1px, transparent 1px);
104
+ background-size: 60px 60px;
105
+ }
106
+
107
+ /* Glow Effects */
108
+ .glow-teal {
109
+ box-shadow: var(--glow-teal);
110
+ }
111
+ .glow-teal-strong {
112
+ box-shadow: var(--glow-teal-strong);
113
+ }
114
+
115
+ /* Status Badges */
116
+ .badge {
117
+ @apply px-3 py-1 text-xs font-bold rounded-full uppercase tracking-wider;
118
+ }
119
+ .badge-high { @apply bg-red-500/15 text-red-400 border border-red-500/30; }
120
+ .badge-medium { @apply bg-amber-500/15 text-amber-400 border border-amber-500/30; }
121
+ .badge-low { @apply bg-emerald-500/15 text-emerald-400 border border-emerald-500/30; }
122
+
123
+ /* Input Styles */
124
+ .input-field {
125
+ @apply w-full p-3 bg-navy-900/80 border border-navy-600/60 rounded-xl text-white
126
+ placeholder:text-gray-500 focus:ring-2 focus:ring-teal-500/40 focus:border-teal-500/50
127
+ transition-all duration-200 outline-none;
128
+ }
129
+ }
130
+
131
+ @layer utilities {
132
+ .text-balance {
133
+ text-wrap: balance;
134
+ }
135
+
136
+ /* Gradient Text */
137
+ .gradient-text {
138
+ @apply bg-clip-text text-transparent bg-gradient-to-r from-white via-white to-teal-400;
139
+ }
140
+ .gradient-text-teal {
141
+ @apply bg-clip-text text-transparent bg-gradient-to-r from-teal-400 to-teal-300;
142
+ }
143
+
144
+ /* Animated Gradient Border */
145
+ .animated-border {
146
+ position: relative;
147
+ overflow: hidden;
148
+ }
149
+ .animated-border::before {
150
+ content: '';
151
+ position: absolute;
152
+ inset: 0;
153
+ border-radius: inherit;
154
+ padding: 1px;
155
+ background: linear-gradient(135deg, #00D4B4, transparent, #1E3A6E, transparent, #00D4B4);
156
+ background-size: 300% 300%;
157
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
158
+ -webkit-mask-composite: xor;
159
+ mask-composite: exclude;
160
+ animation: gradient-shift 6s linear infinite;
161
+ }
162
+
163
+ @keyframes gradient-shift {
164
+ 0% { background-position: 0% 50%; }
165
+ 50% { background-position: 100% 50%; }
166
+ 100% { background-position: 0% 50%; }
167
+ }
168
+
169
+ /* Floating animation */
170
+ .float {
171
+ animation: float 6s ease-in-out infinite;
172
+ }
173
+ @keyframes float {
174
+ 0%, 100% { transform: translateY(0px); }
175
+ 50% { transform: translateY(-10px); }
176
+ }
177
+
178
+ /* Fade in up */
179
+ .animate-fade-in-up {
180
+ animation: fadeInUp 0.6s ease-out forwards;
181
+ opacity: 0;
182
+ }
183
+ @keyframes fadeInUp {
184
+ from { opacity: 0; transform: translateY(20px); }
185
+ to { opacity: 1; transform: translateY(0); }
186
+ }
187
+
188
+ /* Stagger delays */
189
+ .delay-100 { animation-delay: 100ms; }
190
+ .delay-200 { animation-delay: 200ms; }
191
+ .delay-300 { animation-delay: 300ms; }
192
+ .delay-400 { animation-delay: 400ms; }
193
+ .delay-500 { animation-delay: 500ms; }
194
+ .delay-600 { animation-delay: 600ms; }
195
+ }
frontend/app/profile/api-keys/page.jsx ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import { useEffect, useState } from 'react'
3
+ import { Copy, Key, Plus, Trash2, AlertCircle } from 'lucide-react'
4
+ import ProtectedRoute from '@/components/auth/ProtectedRoute'
5
+ import { apiClient } from '@/lib/api/client'
6
+
7
+ export default function APIKeysPage() {
8
+ const [keys, setKeys] = useState([])
9
+ const [name, setName] = useState('Default key')
10
+ const [plainKey, setPlainKey] = useState(null)
11
+ const [error, setError] = useState(null)
12
+ const [loading, setLoading] = useState(false)
13
+
14
+ const loadKeys = async () => {
15
+ const res = await apiClient.get('/api/v1/users/api-keys')
16
+ setKeys(res.data)
17
+ }
18
+
19
+ useEffect(() => {
20
+ loadKeys().catch(() => setError('Could not load API keys.'))
21
+ }, [])
22
+
23
+ const createKey = async (e) => {
24
+ e.preventDefault()
25
+ setLoading(true)
26
+ setError(null)
27
+ try {
28
+ const res = await apiClient.post('/api/v1/users/api-keys', {
29
+ name,
30
+ permissions: ['analyze', 'chat', 'report'],
31
+ rate_limit_per_hour: 100,
32
+ })
33
+ setPlainKey(res.data.plain_key)
34
+ setName('Default key')
35
+ await loadKeys()
36
+ } catch (err) {
37
+ setError(err.response?.data?.message || 'Could not create API key.')
38
+ } finally {
39
+ setLoading(false)
40
+ }
41
+ }
42
+
43
+ const revokeKey = async (id) => {
44
+ await apiClient.delete(`/api/v1/users/api-keys/${id}`)
45
+ await loadKeys()
46
+ }
47
+
48
+ return (
49
+ <ProtectedRoute>
50
+ <div className="max-w-5xl mx-auto px-4 py-10">
51
+ <div className="mb-8">
52
+ <h1 className="text-3xl font-bold text-white">API Keys</h1>
53
+ <p className="text-gray-400 mt-2">Create and revoke keys for scripted access to MedSight endpoints.</p>
54
+ </div>
55
+
56
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
57
+ <form onSubmit={createKey} className="glass-card p-6 space-y-4 lg:col-span-1">
58
+ <div className="flex items-center gap-2 text-teal-400 font-semibold">
59
+ <Key className="w-5 h-5" /> New Key
60
+ </div>
61
+ {error && (
62
+ <div className="flex items-center gap-2 text-sm text-red-300 bg-red-500/10 border border-red-500/20 rounded-xl px-3 py-2">
63
+ <AlertCircle className="w-4 h-4" /> {error}
64
+ </div>
65
+ )}
66
+ <input value={name} onChange={(e) => setName(e.target.value)} className="input-field" minLength={1} maxLength={50} />
67
+ <button disabled={loading} className="btn-primary w-full flex items-center justify-center gap-2 disabled:opacity-60">
68
+ <Plus className="w-4 h-4" /> {loading ? 'Creating...' : 'Create Key'}
69
+ </button>
70
+ </form>
71
+
72
+ <div className="lg:col-span-2 space-y-4">
73
+ {plainKey && (
74
+ <div className="glass-card p-5 border-teal-500/30">
75
+ <p className="text-sm text-teal-300 font-semibold mb-2">Copy this key now. It will not be shown again.</p>
76
+ <div className="flex gap-2">
77
+ <code className="flex-1 bg-navy-950 border border-navy-700 rounded-lg px-3 py-2 text-xs text-gray-200 break-all">{plainKey}</code>
78
+ <button onClick={() => navigator.clipboard.writeText(plainKey)} className="btn-secondary !px-3" title="Copy key">
79
+ <Copy className="w-4 h-4" />
80
+ </button>
81
+ </div>
82
+ </div>
83
+ )}
84
+
85
+ <div className="glass-card overflow-hidden">
86
+ <div className="p-4 border-b border-navy-700/50 text-sm font-semibold text-gray-300">Existing Keys</div>
87
+ {keys.length === 0 ? (
88
+ <p className="p-6 text-sm text-gray-500">No API keys created yet.</p>
89
+ ) : (
90
+ <div className="divide-y divide-navy-700/40">
91
+ {keys.map((key) => (
92
+ <div key={key.id} className="p-4 flex items-center justify-between gap-4">
93
+ <div>
94
+ <p className="text-white font-medium">{key.name}</p>
95
+ <p className="text-xs text-gray-500">{key.key_prefix}... · {key.is_active ? 'Active' : 'Revoked'} · {key.usage_count} uses</p>
96
+ </div>
97
+ {key.is_active && (
98
+ <button onClick={() => revokeKey(key.id)} className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg" title="Revoke key">
99
+ <Trash2 className="w-4 h-4" />
100
+ </button>
101
+ )}
102
+ </div>
103
+ ))}
104
+ </div>
105
+ )}
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ </ProtectedRoute>
111
+ )
112
+ }
frontend/app/profile/page.jsx ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import { useEffect, useState } from 'react'
3
+ import { Save, User, Mail, AlertCircle, CheckCircle2 } from 'lucide-react'
4
+ import ProtectedRoute from '@/components/auth/ProtectedRoute'
5
+ import { apiClient } from '@/lib/api/client'
6
+ import { useAuth } from '@/lib/auth/AuthContext'
7
+
8
+ export default function ProfilePage() {
9
+ const { user, refreshAccessToken } = useAuth()
10
+ const [fullName, setFullName] = useState('')
11
+ const [profilePictureUrl, setProfilePictureUrl] = useState('')
12
+ const [status, setStatus] = useState(null)
13
+ const [loading, setLoading] = useState(false)
14
+
15
+ useEffect(() => {
16
+ if (user) {
17
+ setFullName(user.full_name || '')
18
+ setProfilePictureUrl(user.profile_picture_url || '')
19
+ }
20
+ }, [user])
21
+
22
+ const saveProfile = async (e) => {
23
+ e.preventDefault()
24
+ setLoading(true)
25
+ setStatus(null)
26
+ try {
27
+ await apiClient.patch('/api/v1/users/me', {
28
+ full_name: fullName,
29
+ profile_picture_url: profilePictureUrl || null,
30
+ })
31
+ await refreshAccessToken()
32
+ setStatus({ type: 'success', message: 'Profile updated.' })
33
+ } catch (err) {
34
+ setStatus({ type: 'error', message: err.response?.data?.message || 'Could not update profile.' })
35
+ } finally {
36
+ setLoading(false)
37
+ }
38
+ }
39
+
40
+ return (
41
+ <ProtectedRoute>
42
+ <div className="max-w-3xl mx-auto px-4 py-10">
43
+ <div className="mb-8">
44
+ <h1 className="text-3xl font-bold text-white">Profile Settings</h1>
45
+ <p className="text-gray-400 mt-2">Manage the account used for analyses, reports, and API keys.</p>
46
+ </div>
47
+
48
+ <form onSubmit={saveProfile} className="glass-card p-6 space-y-5">
49
+ {status && (
50
+ <div className={`flex items-center gap-2 px-4 py-3 rounded-xl border ${status.type === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-300' : 'bg-red-500/10 border-red-500/20 text-red-300'}`}>
51
+ {status.type === 'success' ? <CheckCircle2 className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
52
+ <p className="text-sm">{status.message}</p>
53
+ </div>
54
+ )}
55
+
56
+ <div>
57
+ <label className="block text-sm font-medium text-gray-300 mb-2">Email</label>
58
+ <div className="relative">
59
+ <Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
60
+ <input value={user?.email || ''} disabled className="input-field pl-10 opacity-70" />
61
+ </div>
62
+ </div>
63
+
64
+ <div>
65
+ <label className="block text-sm font-medium text-gray-300 mb-2">Full Name</label>
66
+ <div className="relative">
67
+ <User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
68
+ <input value={fullName} onChange={(e) => setFullName(e.target.value)} className="input-field pl-10" minLength={2} required />
69
+ </div>
70
+ </div>
71
+
72
+ <div>
73
+ <label className="block text-sm font-medium text-gray-300 mb-2">Profile Image URL</label>
74
+ <input value={profilePictureUrl} onChange={(e) => setProfilePictureUrl(e.target.value)} className="input-field" placeholder="https://example.com/avatar.png" />
75
+ </div>
76
+
77
+ <button disabled={loading} className="btn-primary flex items-center gap-2 disabled:opacity-60">
78
+ <Save className="w-4 h-4" /> {loading ? 'Saving...' : 'Save Changes'}
79
+ </button>
80
+ </form>
81
+ </div>
82
+ </ProtectedRoute>
83
+ )
84
+ }
frontend/components/ui/dialog.jsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import * as React from 'react'
3
+ import { motion, AnimatePresence } from 'framer-motion'
4
+ import { X } from 'lucide-react'
5
+ import { clsx } from 'clsx'
6
+ import { twMerge } from 'tailwind-merge'
7
+
8
+ function cn(...inputs) {
9
+ return twMerge(clsx(inputs))
10
+ }
11
+
12
+ const Dialog = ({ children, open, onOpenChange }) => {
13
+ return (
14
+ <AnimatePresence>
15
+ {open && (
16
+ <div className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden">
17
+ <motion.div
18
+ initial={{ opacity: 0 }}
19
+ animate={{ opacity: 1 }}
20
+ exit={{ opacity: 0 }}
21
+ onClick={() => onOpenChange(false)}
22
+ className="fixed inset-0 bg-black/60 backdrop-blur-sm"
23
+ />
24
+ <motion.div
25
+ initial={{ opacity: 0, scale: 0.95, y: 20 }}
26
+ animate={{ opacity: 1, scale: 1, y: 0 }}
27
+ exit={{ opacity: 0, scale: 0.95, y: 20 }}
28
+ className="relative z-50 w-full max-w-lg overflow-hidden border border-navy-600 bg-navy-800 p-6 shadow-2xl rounded-xl"
29
+ >
30
+ {children}
31
+ <button
32
+ onClick={() => onOpenChange(false)}
33
+ className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 outline-none"
34
+ >
35
+ <X className="h-4 w-4 text-white" />
36
+ <span className="sr-only">Close</span>
37
+ </button>
38
+ </motion.div>
39
+ </div>
40
+ )}
41
+ </AnimatePresence>
42
+ )
43
+ }
44
+
45
+ const DialogContent = ({ children, className }) => {
46
+ return <div className={cn("mt-2", className)}>{children}</div>
47
+ }
48
+
49
+ const DialogHeader = ({ children, className }) => {
50
+ return <div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}>{children}</div>
51
+ }
52
+
53
+ const DialogTitle = ({ children, className }) => {
54
+ return <h2 className={cn("text-lg font-semibold leading-none tracking-tight text-white", className)}>{children}</h2>
55
+ }
56
+
57
+ export { Dialog, DialogContent, DialogHeader, DialogTitle }
frontend/components/ui/dropdown-menu.jsx ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import * as React from 'react'
3
+ import { motion, AnimatePresence } from 'framer-motion'
4
+ import { clsx } from 'clsx'
5
+ import { twMerge } from 'tailwind-merge'
6
+
7
+ function cn(...inputs) {
8
+ return twMerge(clsx(inputs))
9
+ }
10
+
11
+ const DropdownMenu = ({ children }) => {
12
+ const [open, setOpen] = React.useState(false)
13
+ const containerRef = React.useRef(null)
14
+
15
+ React.useEffect(() => {
16
+ const handleClickOutside = (event) => {
17
+ if (containerRef.current && !containerRef.current.contains(event.target)) {
18
+ setOpen(false)
19
+ }
20
+ }
21
+ document.addEventListener('mousedown', handleClickOutside)
22
+ return () => document.removeEventListener('mousedown', handleClickOutside)
23
+ }, [])
24
+
25
+ return (
26
+ <div className="relative inline-block text-left" ref={containerRef}>
27
+ {React.Children.map(children, (child) => {
28
+ if (child.type === DropdownMenuTrigger) {
29
+ return React.cloneElement(child, { open, setOpen })
30
+ }
31
+ if (child.type === DropdownMenuContent) {
32
+ return (
33
+ <AnimatePresence>
34
+ {open && React.cloneElement(child, { setOpen })}
35
+ </AnimatePresence>
36
+ )
37
+ }
38
+ return child
39
+ })}
40
+ </div>
41
+ )
42
+ }
43
+
44
+ const DropdownMenuTrigger = ({ children, open, setOpen, className, ...props }) => {
45
+ return (
46
+ <button
47
+ onClick={() => setOpen(!open)}
48
+ className={cn("flex items-center outline-none", className)}
49
+ {...props}
50
+ >
51
+ {children}
52
+ </button>
53
+ )
54
+ }
55
+
56
+ const DropdownMenuContent = ({ children, setOpen, className, align = "end", ...props }) => {
57
+ return (
58
+ <motion.div
59
+ initial={{ opacity: 0, scale: 0.95, y: -10 }}
60
+ animate={{ opacity: 1, scale: 1, y: 0 }}
61
+ exit={{ opacity: 0, scale: 0.95, y: -10 }}
62
+ transition={{ duration: 0.1 }}
63
+ className={cn(
64
+ "absolute z-50 mt-2 min-w-[8rem] overflow-hidden rounded-md border border-navy-600 bg-navy-800 p-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none",
65
+ align === "end" ? "right-0" : "left-0",
66
+ className
67
+ )}
68
+ {...props}
69
+ >
70
+ {React.Children.map(children, (child) => {
71
+ if (React.isValidElement(child)) {
72
+ return React.cloneElement(child, { onClick: () => {
73
+ if (child.props.onClick) child.props.onClick()
74
+ setOpen(false)
75
+ }})
76
+ }
77
+ return child
78
+ })}
79
+ </motion.div>
80
+ )
81
+ }
82
+
83
+ const DropdownMenuItem = ({ children, className, onClick, ...props }) => {
84
+ return (
85
+ <div
86
+ onClick={onClick}
87
+ className={cn(
88
+ "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-navy-700 hover:text-teal-400 focus:bg-navy-700 focus:text-teal-400",
89
+ className
90
+ )}
91
+ {...props}
92
+ >
93
+ {children}
94
+ </div>
95
+ )
96
+ }
97
+
98
+ const DropdownMenuLabel = ({ children, className, ...props }) => {
99
+ return (
100
+ <div className={cn("px-2 py-1.5 text-sm font-semibold", className)} {...props}>
101
+ {children}
102
+ </div>
103
+ )
104
+ }
105
+
106
+ const DropdownMenuSeparator = ({ className, ...props }) => {
107
+ return <div className={cn("-mx-1 my-1 h-px bg-navy-600", className)} {...props} />
108
+ }
109
+
110
+ export {
111
+ DropdownMenu,
112
+ DropdownMenuTrigger,
113
+ DropdownMenuContent,
114
+ DropdownMenuItem,
115
+ DropdownMenuLabel,
116
+ DropdownMenuSeparator,
117
+ }
frontend/components/ui/toaster.jsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+ import React from 'react'
3
+
4
+ export function Toaster() {
5
+ return <div id="toaster-container" className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" />
6
+ }
7
+
8
+ export function toast(message, type = 'info') {
9
+ const container = document.getElementById('toaster-container')
10
+ if (!container) return
11
+
12
+ const toastElement = document.createElement('div')
13
+ const colors = {
14
+ error: 'bg-red-500 border-red-600',
15
+ success: 'bg-teal-500 border-teal-600',
16
+ info: 'bg-navy-700 border-navy-600'
17
+ }
18
+
19
+ toastElement.className = `${colors[type] || colors.info} text-white px-4 py-3 rounded-lg shadow-xl border animate-in slide-in-from-right-full transition-all duration-300 opacity-0`
20
+ toastElement.innerText = message
21
+
22
+ container.appendChild(toastElement)
23
+
24
+ // Trigger animation
25
+ setTimeout(() => {
26
+ toastElement.classList.remove('opacity-0')
27
+ }, 10)
28
+
29
+ // Remove after 3 seconds
30
+ setTimeout(() => {
31
+ toastElement.classList.add('opacity-0')
32
+ setTimeout(() => {
33
+ container.removeChild(toastElement)
34
+ }, 300)
35
+ }, 3000)
36
+ }
frontend/jsconfig.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "paths": {
5
+ "@/*": ["./*"]
6
+ }
7
+ }
8
+ }
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "medsight-frontend",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint"
10
+ },
11
+ "dependencies": {
12
+ "axios": "^1.6.7",
13
+ "clsx": "^2.1.0",
14
+ "date-fns": "^3.3.1",
15
+ "framer-motion": "^11.0.3",
16
+ "lucide-react": "^0.330.0",
17
+ "next": "14.1.0",
18
+ "react": "^18.2.0",
19
+ "react-dom": "^18.2.0",
20
+ "recharts": "^2.12.0",
21
+ "tailwind-merge": "^2.2.1",
22
+ "tailwindcss-animate": "^1.0.7"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^20.11.16",
26
+ "@types/react": "^18.2.55",
27
+ "@types/react-dom": "^18.2.19",
28
+ "autoprefixer": "^10.4.17",
29
+ "eslint": "^8.56.0",
30
+ "eslint-config-next": "14.1.0",
31
+ "postcss": "^8.4.35",
32
+ "tailwindcss": "^3.4.1",
33
+ "typescript": "^5.3.3"
34
+ }
35
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }