winebox 0.1.3__py3-none-any.whl → 0.1.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- winebox/__init__.py +1 -1
- winebox/config.py +36 -5
- winebox/main.py +48 -1
- winebox/models/user.py +2 -0
- winebox/routers/auth.py +117 -3
- winebox/routers/wines.py +130 -71
- winebox/services/image_storage.py +138 -9
- winebox/services/vision.py +50 -23
- winebox/static/css/style.css +201 -0
- winebox/static/index.html +176 -19
- winebox/static/js/app.js +343 -62
- {winebox-0.1.3.dist-info → winebox-0.1.5.dist-info}/METADATA +4 -1
- {winebox-0.1.3.dist-info → winebox-0.1.5.dist-info}/RECORD +16 -16
- {winebox-0.1.3.dist-info → winebox-0.1.5.dist-info}/WHEEL +0 -0
- {winebox-0.1.3.dist-info → winebox-0.1.5.dist-info}/entry_points.txt +0 -0
- {winebox-0.1.3.dist-info → winebox-0.1.5.dist-info}/licenses/LICENSE +0 -0
|
@@ -4,42 +4,171 @@ import uuid
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
6
|
import aiofiles
|
|
7
|
-
from fastapi import UploadFile
|
|
7
|
+
from fastapi import HTTPException, UploadFile, status
|
|
8
8
|
|
|
9
9
|
from winebox.config import settings
|
|
10
10
|
|
|
11
|
+
# Allowed MIME types for image uploads
|
|
12
|
+
ALLOWED_MIME_TYPES = {
|
|
13
|
+
"image/jpeg",
|
|
14
|
+
"image/png",
|
|
15
|
+
"image/gif",
|
|
16
|
+
"image/webp",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# File extension to MIME type mapping
|
|
20
|
+
EXTENSION_MIME_MAP = {
|
|
21
|
+
".jpg": "image/jpeg",
|
|
22
|
+
".jpeg": "image/jpeg",
|
|
23
|
+
".png": "image/png",
|
|
24
|
+
".gif": "image/gif",
|
|
25
|
+
".webp": "image/webp",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Magic byte signatures for image formats
|
|
29
|
+
# Each entry is (magic_bytes, offset, detected_extension)
|
|
30
|
+
IMAGE_MAGIC_SIGNATURES = [
|
|
31
|
+
# JPEG: starts with FF D8 FF
|
|
32
|
+
(b"\xff\xd8\xff", 0, ".jpg"),
|
|
33
|
+
# PNG: starts with 89 50 4E 47 0D 0A 1A 0A
|
|
34
|
+
(b"\x89PNG\r\n\x1a\n", 0, ".png"),
|
|
35
|
+
# GIF87a and GIF89a
|
|
36
|
+
(b"GIF87a", 0, ".gif"),
|
|
37
|
+
(b"GIF89a", 0, ".gif"),
|
|
38
|
+
# WebP: starts with RIFF....WEBP
|
|
39
|
+
(b"RIFF", 0, ".webp"), # Additional check for WEBP at offset 8
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def detect_image_type(content: bytes) -> str | None:
|
|
44
|
+
"""Detect image type from file content using magic bytes.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
content: The file content bytes.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The detected extension (e.g., ".jpg") or None if not a valid image.
|
|
51
|
+
"""
|
|
52
|
+
if len(content) < 12:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
for magic, offset, ext in IMAGE_MAGIC_SIGNATURES:
|
|
56
|
+
if content[offset:offset + len(magic)] == magic:
|
|
57
|
+
# Special case for WebP: verify WEBP signature at offset 8
|
|
58
|
+
if ext == ".webp":
|
|
59
|
+
if content[8:12] != b"WEBP":
|
|
60
|
+
continue
|
|
61
|
+
return ext
|
|
62
|
+
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FileSizeExceededError(Exception):
|
|
67
|
+
"""Raised when uploaded file exceeds size limit."""
|
|
68
|
+
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class InvalidFileTypeError(Exception):
|
|
73
|
+
"""Raised when uploaded file has invalid type."""
|
|
74
|
+
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class InvalidMagicBytesError(Exception):
|
|
79
|
+
"""Raised when file content doesn't match a valid image format."""
|
|
80
|
+
|
|
81
|
+
pass
|
|
82
|
+
|
|
11
83
|
|
|
12
84
|
class ImageStorageService:
|
|
13
85
|
"""Service for storing and managing wine label images."""
|
|
14
86
|
|
|
15
|
-
def __init__(
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
storage_path: Path | None = None,
|
|
90
|
+
max_size_bytes: int | None = None,
|
|
91
|
+
) -> None:
|
|
16
92
|
"""Initialize the image storage service.
|
|
17
93
|
|
|
18
94
|
Args:
|
|
19
95
|
storage_path: Path to store images. Defaults to config setting.
|
|
96
|
+
max_size_bytes: Maximum file size in bytes. Defaults to config setting.
|
|
20
97
|
"""
|
|
21
98
|
self.storage_path = storage_path or settings.image_storage_path
|
|
22
99
|
self.storage_path.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
self.max_size_bytes = max_size_bytes or settings.max_upload_size_bytes
|
|
101
|
+
|
|
102
|
+
def _validate_extension(self, filename: str | None) -> str:
|
|
103
|
+
"""Validate and return the file extension.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
filename: The original filename.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Valid extension (e.g., ".jpg").
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
InvalidFileTypeError: If extension is not allowed.
|
|
113
|
+
"""
|
|
114
|
+
if not filename:
|
|
115
|
+
return ".jpg"
|
|
116
|
+
|
|
117
|
+
ext = Path(filename).suffix.lower()
|
|
118
|
+
if ext not in EXTENSION_MIME_MAP:
|
|
119
|
+
raise InvalidFileTypeError(
|
|
120
|
+
f"Invalid file type. Allowed types: {', '.join(EXTENSION_MIME_MAP.keys())}"
|
|
121
|
+
)
|
|
122
|
+
return ext
|
|
23
123
|
|
|
24
124
|
async def save_image(self, upload_file: UploadFile) -> str:
|
|
25
|
-
"""Save an uploaded image file.
|
|
125
|
+
"""Save an uploaded image file with size, type, and content validation.
|
|
26
126
|
|
|
27
127
|
Args:
|
|
28
128
|
upload_file: The uploaded file from FastAPI.
|
|
29
129
|
|
|
30
130
|
Returns:
|
|
31
131
|
The filename of the saved image.
|
|
32
|
-
"""
|
|
33
|
-
# Generate unique filename
|
|
34
|
-
ext = Path(upload_file.filename or "image.jpg").suffix.lower()
|
|
35
|
-
if ext not in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
|
|
36
|
-
ext = ".jpg"
|
|
37
132
|
|
|
133
|
+
Raises:
|
|
134
|
+
HTTPException: If file exceeds size limit, has invalid type, or
|
|
135
|
+
content doesn't match a valid image format.
|
|
136
|
+
"""
|
|
137
|
+
# Validate extension first
|
|
138
|
+
try:
|
|
139
|
+
declared_ext = self._validate_extension(upload_file.filename)
|
|
140
|
+
except InvalidFileTypeError as e:
|
|
141
|
+
raise HTTPException(
|
|
142
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
143
|
+
detail=str(e),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Read content with size limit check
|
|
147
|
+
content = await upload_file.read()
|
|
148
|
+
if len(content) > self.max_size_bytes:
|
|
149
|
+
max_mb = self.max_size_bytes / (1024 * 1024)
|
|
150
|
+
raise HTTPException(
|
|
151
|
+
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
|
152
|
+
detail=f"File size exceeds maximum allowed size of {max_mb:.1f} MB",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Validate magic bytes - ensure file content matches a valid image format
|
|
156
|
+
detected_ext = detect_image_type(content)
|
|
157
|
+
if detected_ext is None:
|
|
158
|
+
raise HTTPException(
|
|
159
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
160
|
+
detail="Invalid file content. File does not appear to be a valid image.",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Use the detected extension (more reliable than declared extension)
|
|
164
|
+
# This prevents attacks where malicious files are renamed to .jpg/.png
|
|
165
|
+
ext = detected_ext
|
|
166
|
+
|
|
167
|
+
# Generate unique filename with detected extension
|
|
38
168
|
filename = f"{uuid.uuid4()}{ext}"
|
|
39
169
|
file_path = self.storage_path / filename
|
|
40
170
|
|
|
41
171
|
# Save file
|
|
42
|
-
content = await upload_file.read()
|
|
43
172
|
async with aiofiles.open(file_path, "wb") as f:
|
|
44
173
|
await f.write(content)
|
|
45
174
|
|
winebox/services/vision.py
CHANGED
|
@@ -37,30 +37,47 @@ class ClaudeVisionService:
|
|
|
37
37
|
|
|
38
38
|
def __init__(self) -> None:
|
|
39
39
|
"""Initialize the Claude Vision service."""
|
|
40
|
-
self.
|
|
40
|
+
self._default_client = None
|
|
41
|
+
|
|
42
|
+
def _get_system_api_key(self) -> str | None:
|
|
43
|
+
"""Get the system-wide API key from settings or environment."""
|
|
44
|
+
return settings.anthropic_api_key or os.getenv("ANTHROPIC_API_KEY")
|
|
45
|
+
|
|
46
|
+
def _get_client(self, user_api_key: str | None = None):
|
|
47
|
+
"""Get an Anthropic client, using user key if provided, else system key."""
|
|
48
|
+
try:
|
|
49
|
+
import anthropic
|
|
50
|
+
|
|
51
|
+
# Use user's API key if provided, otherwise use system key
|
|
52
|
+
api_key = user_api_key or self._get_system_api_key()
|
|
53
|
+
if not api_key:
|
|
54
|
+
raise ValueError("No Anthropic API key configured")
|
|
55
|
+
|
|
56
|
+
# If using system key, cache the client
|
|
57
|
+
if not user_api_key:
|
|
58
|
+
if self._default_client is None:
|
|
59
|
+
self._default_client = anthropic.Anthropic(api_key=api_key)
|
|
60
|
+
return self._default_client
|
|
61
|
+
|
|
62
|
+
# Create a new client for user-specific key
|
|
63
|
+
return anthropic.Anthropic(api_key=api_key)
|
|
64
|
+
except ImportError:
|
|
65
|
+
logger.error("anthropic package is not installed")
|
|
66
|
+
raise
|
|
41
67
|
|
|
42
68
|
@property
|
|
43
69
|
def client(self):
|
|
44
|
-
"""Lazy-load the Anthropic client."""
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
self._client = anthropic.Anthropic(api_key=api_key)
|
|
55
|
-
except ImportError:
|
|
56
|
-
logger.error("anthropic package is not installed")
|
|
57
|
-
raise
|
|
58
|
-
return self._client
|
|
59
|
-
|
|
60
|
-
def is_available(self) -> bool:
|
|
61
|
-
"""Check if Claude Vision is available."""
|
|
70
|
+
"""Lazy-load the default Anthropic client (for backward compatibility)."""
|
|
71
|
+
return self._get_client()
|
|
72
|
+
|
|
73
|
+
def is_available(self, user_api_key: str | None = None) -> bool:
|
|
74
|
+
"""Check if Claude Vision is available.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
user_api_key: Optional user-specific API key to check.
|
|
78
|
+
"""
|
|
62
79
|
try:
|
|
63
|
-
api_key =
|
|
80
|
+
api_key = user_api_key or self._get_system_api_key()
|
|
64
81
|
return bool(api_key) and settings.use_claude_vision
|
|
65
82
|
except Exception:
|
|
66
83
|
return False
|
|
@@ -68,13 +85,15 @@ class ClaudeVisionService:
|
|
|
68
85
|
async def analyze_label(
|
|
69
86
|
self,
|
|
70
87
|
image_data: bytes,
|
|
71
|
-
media_type: str = "image/jpeg"
|
|
88
|
+
media_type: str = "image/jpeg",
|
|
89
|
+
user_api_key: str | None = None,
|
|
72
90
|
) -> dict[str, Any]:
|
|
73
91
|
"""Analyze a wine label image using Claude Vision.
|
|
74
92
|
|
|
75
93
|
Args:
|
|
76
94
|
image_data: Raw image data as bytes.
|
|
77
95
|
media_type: MIME type of the image (image/jpeg, image/png, etc.)
|
|
96
|
+
user_api_key: Optional user-specific API key.
|
|
78
97
|
|
|
79
98
|
Returns:
|
|
80
99
|
Dictionary with parsed wine information.
|
|
@@ -83,8 +102,11 @@ class ClaudeVisionService:
|
|
|
83
102
|
# Encode image to base64
|
|
84
103
|
image_base64 = base64.standard_b64encode(image_data).decode("utf-8")
|
|
85
104
|
|
|
105
|
+
# Get client (uses user key if provided, else system key)
|
|
106
|
+
client = self._get_client(user_api_key)
|
|
107
|
+
|
|
86
108
|
# Call Claude API with vision
|
|
87
|
-
message =
|
|
109
|
+
message = client.messages.create(
|
|
88
110
|
model="claude-sonnet-4-20250514",
|
|
89
111
|
max_tokens=1024,
|
|
90
112
|
messages=[
|
|
@@ -146,6 +168,7 @@ class ClaudeVisionService:
|
|
|
146
168
|
back_image_data: bytes | None = None,
|
|
147
169
|
front_media_type: str = "image/jpeg",
|
|
148
170
|
back_media_type: str = "image/jpeg",
|
|
171
|
+
user_api_key: str | None = None,
|
|
149
172
|
) -> dict[str, Any]:
|
|
150
173
|
"""Analyze front and back wine label images.
|
|
151
174
|
|
|
@@ -154,11 +177,15 @@ class ClaudeVisionService:
|
|
|
154
177
|
back_image_data: Optional back label image data.
|
|
155
178
|
front_media_type: MIME type of front image.
|
|
156
179
|
back_media_type: MIME type of back image.
|
|
180
|
+
user_api_key: Optional user-specific API key.
|
|
157
181
|
|
|
158
182
|
Returns:
|
|
159
183
|
Combined analysis results.
|
|
160
184
|
"""
|
|
161
185
|
try:
|
|
186
|
+
# Get client (uses user key if provided, else system key)
|
|
187
|
+
client = self._get_client(user_api_key)
|
|
188
|
+
|
|
162
189
|
# Build message content with images
|
|
163
190
|
content = [
|
|
164
191
|
{
|
|
@@ -199,7 +226,7 @@ class ClaudeVisionService:
|
|
|
199
226
|
])
|
|
200
227
|
|
|
201
228
|
# Call Claude API
|
|
202
|
-
message =
|
|
229
|
+
message = client.messages.create(
|
|
203
230
|
model="claude-sonnet-4-20250514",
|
|
204
231
|
max_tokens=1024,
|
|
205
232
|
messages=[{"role": "user", "content": content}],
|
winebox/static/css/style.css
CHANGED
|
@@ -555,6 +555,140 @@ h3 {
|
|
|
555
555
|
max-width: 400px;
|
|
556
556
|
}
|
|
557
557
|
|
|
558
|
+
.modal-content.modal-large {
|
|
559
|
+
max-width: 900px;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/* Checkin Confirmation Modal */
|
|
563
|
+
.checkin-confirm-header {
|
|
564
|
+
text-align: center;
|
|
565
|
+
margin-bottom: 1.5rem;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.checkin-confirm-header h3 {
|
|
569
|
+
color: var(--primary-color);
|
|
570
|
+
margin-bottom: 0.25rem;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
.checkin-confirm-subtitle {
|
|
574
|
+
color: var(--text-muted);
|
|
575
|
+
font-size: 0.9rem;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
.checkin-confirm-content {
|
|
579
|
+
display: grid;
|
|
580
|
+
grid-template-columns: 200px 1fr;
|
|
581
|
+
gap: 1.5rem;
|
|
582
|
+
margin-bottom: 1.5rem;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
.checkin-confirm-image {
|
|
586
|
+
width: 200px;
|
|
587
|
+
height: 250px;
|
|
588
|
+
border-radius: var(--radius);
|
|
589
|
+
overflow: hidden;
|
|
590
|
+
border: 1px solid var(--border-color);
|
|
591
|
+
background: var(--background-color);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
.checkin-confirm-image img {
|
|
595
|
+
width: 100%;
|
|
596
|
+
height: 100%;
|
|
597
|
+
object-fit: cover;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.checkin-confirm-form .form-grid {
|
|
601
|
+
display: grid;
|
|
602
|
+
grid-template-columns: repeat(2, 1fr);
|
|
603
|
+
gap: 1rem;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
.checkin-confirm-form .form-group {
|
|
607
|
+
margin-bottom: 0;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
.checkin-confirm-form .form-group.full-width {
|
|
611
|
+
grid-column: 1 / -1;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
.checkin-confirm-form label {
|
|
615
|
+
display: block;
|
|
616
|
+
font-size: 0.85rem;
|
|
617
|
+
color: var(--text-muted);
|
|
618
|
+
margin-bottom: 0.25rem;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
.checkin-confirm-form input,
|
|
622
|
+
.checkin-confirm-form textarea {
|
|
623
|
+
width: 100%;
|
|
624
|
+
padding: 0.5rem 0.75rem;
|
|
625
|
+
border: 1px solid var(--border-color);
|
|
626
|
+
border-radius: var(--radius);
|
|
627
|
+
font-size: 0.95rem;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
.checkin-confirm-form textarea {
|
|
631
|
+
resize: vertical;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
.checkin-confirm-ocr {
|
|
635
|
+
margin-bottom: 1.5rem;
|
|
636
|
+
border: 1px solid var(--border-color);
|
|
637
|
+
border-radius: var(--radius);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.checkin-confirm-ocr .collapsible-header {
|
|
641
|
+
padding: 0.75rem 1rem;
|
|
642
|
+
cursor: pointer;
|
|
643
|
+
display: flex;
|
|
644
|
+
justify-content: space-between;
|
|
645
|
+
align-items: center;
|
|
646
|
+
background: var(--background-color);
|
|
647
|
+
border-radius: var(--radius);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
.checkin-confirm-ocr .collapsible-header:hover {
|
|
651
|
+
background: #f0ede8;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
.checkin-confirm-ocr .collapsible-content {
|
|
655
|
+
padding: 1rem;
|
|
656
|
+
border-top: 1px solid var(--border-color);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.checkin-confirm-ocr .ocr-text-container {
|
|
660
|
+
max-height: 200px;
|
|
661
|
+
overflow-y: auto;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
.checkin-confirm-ocr pre {
|
|
665
|
+
white-space: pre-wrap;
|
|
666
|
+
font-size: 0.85rem;
|
|
667
|
+
background: var(--background-color);
|
|
668
|
+
padding: 0.75rem;
|
|
669
|
+
border-radius: var(--radius);
|
|
670
|
+
margin-top: 0.5rem;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
@media (max-width: 768px) {
|
|
674
|
+
.checkin-confirm-content {
|
|
675
|
+
grid-template-columns: 1fr;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
.checkin-confirm-image {
|
|
679
|
+
width: 100%;
|
|
680
|
+
height: 200px;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.checkin-confirm-form .form-grid {
|
|
684
|
+
grid-template-columns: 1fr;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
.modal-content.modal-large {
|
|
688
|
+
max-width: 100%;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
558
692
|
.modal-close {
|
|
559
693
|
position: absolute;
|
|
560
694
|
top: 1rem;
|
|
@@ -1044,6 +1178,19 @@ h3 {
|
|
|
1044
1178
|
font-weight: 500;
|
|
1045
1179
|
}
|
|
1046
1180
|
|
|
1181
|
+
.user-info .username-link {
|
|
1182
|
+
color: rgba(255, 255, 255, 0.9);
|
|
1183
|
+
text-decoration: none;
|
|
1184
|
+
padding: 0.4rem 0.75rem;
|
|
1185
|
+
border-radius: var(--radius);
|
|
1186
|
+
transition: all 0.2s ease;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
.user-info .username-link:hover {
|
|
1190
|
+
background: rgba(255, 255, 255, 0.15);
|
|
1191
|
+
color: white;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1047
1194
|
.user-info .btn {
|
|
1048
1195
|
padding: 0.4rem 0.75rem;
|
|
1049
1196
|
font-size: 0.85rem;
|
|
@@ -1056,6 +1203,60 @@ h3 {
|
|
|
1056
1203
|
background: rgba(255, 255, 255, 0.3);
|
|
1057
1204
|
}
|
|
1058
1205
|
|
|
1206
|
+
/* Settings Page */
|
|
1207
|
+
.settings-section {
|
|
1208
|
+
background: var(--card-background);
|
|
1209
|
+
border-radius: var(--radius-lg);
|
|
1210
|
+
padding: 1.5rem;
|
|
1211
|
+
margin-bottom: 1.5rem;
|
|
1212
|
+
box-shadow: var(--shadow);
|
|
1213
|
+
border: 1px solid var(--border-color);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
.settings-section h3 {
|
|
1217
|
+
color: var(--primary-color);
|
|
1218
|
+
margin-bottom: 1rem;
|
|
1219
|
+
padding-bottom: 0.75rem;
|
|
1220
|
+
border-bottom: 1px solid var(--border-color);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
.settings-description {
|
|
1224
|
+
color: var(--text-muted);
|
|
1225
|
+
font-size: 0.9rem;
|
|
1226
|
+
margin-bottom: 1rem;
|
|
1227
|
+
line-height: 1.5;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
.api-key-status {
|
|
1231
|
+
display: flex;
|
|
1232
|
+
align-items: center;
|
|
1233
|
+
gap: 0.5rem;
|
|
1234
|
+
padding: 0.75rem 1rem;
|
|
1235
|
+
background: var(--background-color);
|
|
1236
|
+
border-radius: var(--radius);
|
|
1237
|
+
margin-bottom: 1rem;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
.api-key-status .status-indicator {
|
|
1241
|
+
width: 10px;
|
|
1242
|
+
height: 10px;
|
|
1243
|
+
border-radius: 50%;
|
|
1244
|
+
background: var(--text-muted);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
.api-key-status.configured .status-indicator {
|
|
1248
|
+
background: var(--success-color);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
.api-key-status.not-configured .status-indicator {
|
|
1252
|
+
background: var(--warning-color);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
.api-key-status .status-text {
|
|
1256
|
+
font-size: 0.9rem;
|
|
1257
|
+
color: var(--text-color);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1059
1260
|
/* Hide main content when not logged in */
|
|
1060
1261
|
body.logged-out main {
|
|
1061
1262
|
display: none;
|