Spaces:
Running
Running
Feature: EN/HR language toggle on login + full app i18n
Browse files- backend/api.py +35 -7
- backend/static/assets/index-BAvZzacE.js +0 -0
- backend/static/assets/index-BVDvGT2M.css +0 -1
- backend/static/assets/index-CfN_TOmj.css +1 -0
- backend/static/assets/index-D8b5zKM1.js +0 -0
- backend/static/index.html +2 -2
- frontend/src/App.vue +21 -5
- frontend/src/api.ts +2 -0
- frontend/src/components/ChatView.vue +11 -7
- frontend/src/components/CitationPanel.vue +8 -3
- frontend/src/components/ImageView.vue +10 -6
- frontend/src/components/LoginPage.vue +72 -15
- frontend/src/components/SidePanel.vue +63 -8
- frontend/src/i18n.ts +194 -0
- frontend/src/types.ts +3 -1
backend/api.py
CHANGED
|
@@ -70,7 +70,8 @@ AVAILABLE_MODELS = {
|
|
| 70 |
},
|
| 71 |
}
|
| 72 |
|
| 73 |
-
|
|
|
|
| 74 |
za studente medicine.
|
| 75 |
|
| 76 |
ULOGA:
|
|
@@ -87,7 +88,30 @@ PRAVILA:
|
|
| 87 |
2. Ako nisi siguran, reci to otvoreno
|
| 88 |
3. Koristi medicinsku terminologiju, ali objasni kompleksne termine
|
| 89 |
4. Budi koncizan ali potpun u odgovorima
|
| 90 |
-
5. Odgovaraj na hrvatskom jeziku"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
# =============================================================================
|
| 93 |
# SINGLETONS
|
|
@@ -215,23 +239,26 @@ def query_rag(query_text: str, top_k: int = RAG_TOP_K):
|
|
| 215 |
return "Nema dostupnog konteksta.", []
|
| 216 |
|
| 217 |
|
| 218 |
-
def generate_chat_response(message: str, history: list, model_name: str = ""):
|
| 219 |
model_name = model_name or DEFAULT_MODEL
|
| 220 |
if model_name not in AVAILABLE_MODELS:
|
| 221 |
model_name = DEFAULT_MODEL
|
| 222 |
|
| 223 |
c = get_client()
|
| 224 |
rag_context, citations = query_rag(message)
|
| 225 |
-
system_prompt =
|
|
|
|
|
|
|
|
|
|
| 226 |
|
| 227 |
contents = [system_prompt]
|
| 228 |
for msg in (history or [])[-10:]:
|
| 229 |
role = msg.get("role", "user")
|
| 230 |
content = msg.get("content", "")
|
| 231 |
if role == "user":
|
| 232 |
-
contents.append(f"
|
| 233 |
else:
|
| 234 |
-
contents.append(f"
|
| 235 |
contents.append(f"Student: {message}")
|
| 236 |
|
| 237 |
response = c.models.generate_content(
|
|
@@ -313,6 +340,7 @@ app.add_middleware(
|
|
| 313 |
class ChatRequest(BaseModel):
|
| 314 |
message: str
|
| 315 |
model: str = ""
|
|
|
|
| 316 |
history: list = []
|
| 317 |
|
| 318 |
|
|
@@ -402,7 +430,7 @@ async def stats():
|
|
| 402 |
async def chat(req: ChatRequest, user=Depends(require_auth)):
|
| 403 |
try:
|
| 404 |
model = req.model or DEFAULT_MODEL
|
| 405 |
-
reply, citations = generate_chat_response(req.message, req.history, model)
|
| 406 |
return {"reply": reply, "citations": citations, "model_used": model}
|
| 407 |
except Exception as e:
|
| 408 |
logger.error(f"Chat error: {e}")
|
|
|
|
| 70 |
},
|
| 71 |
}
|
| 72 |
|
| 73 |
+
SYSTEM_PROMPTS = {
|
| 74 |
+
"hr": """Ti si "Learn Pathophysiology AI", strucni asistent za ucenje patofiziologije
|
| 75 |
za studente medicine.
|
| 76 |
|
| 77 |
ULOGA:
|
|
|
|
| 88 |
2. Ako nisi siguran, reci to otvoreno
|
| 89 |
3. Koristi medicinsku terminologiju, ali objasni kompleksne termine
|
| 90 |
4. Budi koncizan ali potpun u odgovorima
|
| 91 |
+
5. Odgovaraj na hrvatskom jeziku""",
|
| 92 |
+
|
| 93 |
+
"en": """You are "Learn Pathophysiology AI", an expert assistant for learning pathophysiology
|
| 94 |
+
for medical students.
|
| 95 |
+
|
| 96 |
+
ROLE:
|
| 97 |
+
- Explain pathophysiological concepts clearly and precisely
|
| 98 |
+
- Use examples and analogies when possible
|
| 99 |
+
- Connect concepts to clinical practice
|
| 100 |
+
- Respond in English
|
| 101 |
+
|
| 102 |
+
KNOWLEDGE BASE CONTEXT:
|
| 103 |
+
{rag_context}
|
| 104 |
+
|
| 105 |
+
RULES:
|
| 106 |
+
1. Always cite the source when using information from the context
|
| 107 |
+
2. If you are unsure, say so openly
|
| 108 |
+
3. Use medical terminology but explain complex terms
|
| 109 |
+
4. Be concise but thorough in your answers
|
| 110 |
+
5. Respond in English""",
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
def get_system_prompt(lang: str = "hr") -> str:
|
| 114 |
+
return SYSTEM_PROMPTS.get(lang, SYSTEM_PROMPTS["hr"])
|
| 115 |
|
| 116 |
# =============================================================================
|
| 117 |
# SINGLETONS
|
|
|
|
| 239 |
return "Nema dostupnog konteksta.", []
|
| 240 |
|
| 241 |
|
| 242 |
+
def generate_chat_response(message: str, history: list, model_name: str = "", lang: str = "hr"):
|
| 243 |
model_name = model_name or DEFAULT_MODEL
|
| 244 |
if model_name not in AVAILABLE_MODELS:
|
| 245 |
model_name = DEFAULT_MODEL
|
| 246 |
|
| 247 |
c = get_client()
|
| 248 |
rag_context, citations = query_rag(message)
|
| 249 |
+
system_prompt = get_system_prompt(lang).format(rag_context=rag_context)
|
| 250 |
+
|
| 251 |
+
student_label = "Student" if lang == "hr" else "Student"
|
| 252 |
+
assistant_label = "Asistent" if lang == "hr" else "Assistant"
|
| 253 |
|
| 254 |
contents = [system_prompt]
|
| 255 |
for msg in (history or [])[-10:]:
|
| 256 |
role = msg.get("role", "user")
|
| 257 |
content = msg.get("content", "")
|
| 258 |
if role == "user":
|
| 259 |
+
contents.append(f"{student_label}: {content}")
|
| 260 |
else:
|
| 261 |
+
contents.append(f"{assistant_label}: {content}")
|
| 262 |
contents.append(f"Student: {message}")
|
| 263 |
|
| 264 |
response = c.models.generate_content(
|
|
|
|
| 340 |
class ChatRequest(BaseModel):
|
| 341 |
message: str
|
| 342 |
model: str = ""
|
| 343 |
+
lang: str = "hr"
|
| 344 |
history: list = []
|
| 345 |
|
| 346 |
|
|
|
|
| 430 |
async def chat(req: ChatRequest, user=Depends(require_auth)):
|
| 431 |
try:
|
| 432 |
model = req.model or DEFAULT_MODEL
|
| 433 |
+
reply, citations = generate_chat_response(req.message, req.history, model, req.lang)
|
| 434 |
return {"reply": reply, "citations": citations, "model_used": model}
|
| 435 |
except Exception as e:
|
| 436 |
logger.error(f"Chat error: {e}")
|
backend/static/assets/index-BAvZzacE.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/static/assets/index-BVDvGT2M.css
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
@charset "UTF-8";.btn{width:100%;height:100%;position:relative;background:var(--v854fa1ce) no-repeat;background-size:100% 100%;color:var(--v548a9e4c);-webkit-user-select:none;user-select:none;-webkit-mask-image:var(--v854fa1ce);-webkit-mask-size:100% 100%}.btn__capt{width:100%;position:absolute;text-align:center;bottom:0}.btn--d{font-size:14px;letter-spacing:.5px;line-height:180%}.btn--s{height:32px;line-height:32px;min-width:100px}.btn--m{height:48px;line-height:48px;min-width:200px}*:not([disabled])+.btn:hover:before{content:"";position:absolute;width:100%;height:100%;background:var(--ba36690e) no-repeat;background-size:100% 100%;left:0;top:0;mix-blend-mode:screen}*:not([disabled])+.btn:active{background-image:var(--v4e4c04fa)}*:not([disabled])+.btn:active .btn__capt{left:3px;bottom:-2px}[disabled]+.btn{background-image:var(--v512606b8);pointer-events:all!important;color:#646464}.cursored,.cursored *{cursor:none}.cursor{position:relative;width:32px;height:32px;background:var(--v7b062dde) no-repeat}.cursor__container{top:0;left:0;position:fixed;z-index:99;pointer-events:none}.cursor--default-active{animation:cursor-active .5s steps(8) infinite}.cursor--pointer{background-position:-32px -96px}.cursor--pointer-active{animation:cursor-pointer .5s steps(8) infinite}.cursor--pointer-denied{background-position:-64px -96px}.cursor--hold{background-position:-128px -96px}.cursor--arrow-top{animation:cursor-arrow .1s steps(3) infinite;transform:rotate(-90deg)}.cursor--arrow-right{animation:cursor-arrow .1s steps(3) infinite}.cursor--arrow-bottom{animation:cursor-arrow .1s steps(3) infinite;transform:rotate(90deg)}.cursor--arrow-left{animation:cursor-arrow .1s steps(3) infinite;transform:rotate(-180deg)}@keyframes cursor-active{0%{background-position:0 0}to{background-position:-256px 0}}@keyframes cursor-pointer{0%{background-position:0 -64px}to{background-position:-256px -64px}}@keyframes cursor-arrow{0%{background-position:-160px -96px}to{background-position:-256px -96px}}.chat-area[data-v-b84b5170]{flex:1;overflow:hidden;display:flex;flex-direction:column}.messages-container[data-v-b84b5170]{flex:1;overflow-y:auto;padding:1rem 1.5rem;display:flex;flex-direction:column;gap:.75rem}.login-page{width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;background:radial-gradient(ellipse at center,#12122a,#0a0a16,#050510)}.login-card{width:100%;max-width:480px;padding:2rem}.login-frame{background:linear-gradient(180deg,#13132a,#0d0d1f);border:2px solid var(--wc3-gold-dim, #8b7b4f);border-radius:6px;padding:2.5rem 2rem;text-align:center;box-shadow:0 0 40px #c8aa6e14,inset 0 1px #c8aa6e1a}.login-icon{font-size:3rem;margin-bottom:.75rem;filter:drop-shadow(0 0 10px rgba(200,170,110,.4))}.login-frame h1{font-family:var(--font-display, "Cinzel Decorative", serif);font-size:1.5rem;font-weight:900;background:linear-gradient(180deg,#f0d060,#c8aa6e,#8b7b4f);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;letter-spacing:1px;margin-bottom:.25rem;word-break:keep-all}.login-frame .subtitle{font-family:var(--font-body, "Crimson Text", serif);color:var(--wc3-text-dim, #7a6e5a);font-style:italic;font-size:.95rem}.login-prompt{font-family:var(--font-heading, "Cinzel", serif);color:var(--wc3-gold, #c8aa6e);font-size:.9rem;letter-spacing:1.5px;text-transform:uppercase;margin-bottom:1.25rem}.g-btn-container{display:flex;justify-content:center;min-height:44px;margin-bottom:.5rem}.login-loading{color:var(--wc3-gold, #c8aa6e);font-size:.85rem;margin-top:.75rem;animation:pulse 1.5s ease-in-out infinite}.login-error{color:#f66;font-size:.85rem;margin-top:.75rem}.iframe-hint{color:var(--wc3-text-dim, #7a6e5a);font-size:.78rem;margin-top:1rem;line-height:1.6}.direct-link{color:var(--wc3-gold, #c8aa6e);text-decoration:underline;text-underline-offset:2px;word-break:break-all}.direct-link:hover{color:var(--wc3-gold-bright, #f0d060)}.login-footer{font-size:.8rem;color:var(--wc3-text-dim, #7a6e5a);font-style:italic;line-height:1.6}.login-footer-dim{color:var(--wc3-text-muted, #555060);font-size:.75rem}:root{--wc3-bg: #0a0a0f;--wc3-bg-panel: #0f0f1a;--wc3-gold: #c8aa6e;--wc3-gold-bright: #f0d060;--wc3-gold-dim: #8b7b4f;--wc3-text: #d4c4a0;--wc3-text-light: #f0e6d0;--wc3-text-dim: #7a6e5a;--wc3-text-muted: #555060;--wc3-border: #5a4a2a;--wc3-border-dim: #3a2e1e;--wc3-red-soft: rgba(140, 40, 40, .25);--wc3-blue-soft: rgba(30, 50, 90, .35);--font-heading: "Cinzel", serif;--font-display: "Cinzel Decorative", "Cinzel", serif;--font-body: "Crimson Text", serif;--sidebar-w: 270px}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html,body{height:100%;overflow:hidden;background:var(--wc3-bg);color:var(--wc3-text);font-family:var(--font-body);line-height:1.6}#app{height:100vh}::-webkit-scrollbar{width:8px}::-webkit-scrollbar-track{background:#0000004d}::-webkit-scrollbar-thumb{background:linear-gradient(180deg,var(--wc3-gold-dim),#4a3a20);border-radius:2px}::-webkit-scrollbar-thumb:hover{background:var(--wc3-gold)}.app-shell{display:flex;height:100vh;overflow:hidden}.sidebar{width:var(--sidebar-w);min-width:var(--sidebar-w);background:linear-gradient(180deg,#0f0f22,#0a0a18);border-right:2px solid var(--wc3-border);display:flex;flex-direction:column;overflow-y:auto;padding:1rem;box-shadow:2px 0 20px #00000080}.main-content{flex:1;display:flex;flex-direction:column;overflow:hidden;background:radial-gradient(ellipse at top center,#12122a,#0a0a16 60%,#070710)}.app-header{text-align:center;padding:1.25rem 1rem .5rem;flex-shrink:0}.header-frame h1{font-family:var(--font-display);font-size:2rem;font-weight:900;background:linear-gradient(180deg,#f0d060,#c8aa6e,#8b7b4f);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;letter-spacing:3px;filter:drop-shadow(0 2px 4px rgba(0,0,0,.8))}.header-frame .subtitle{font-family:var(--font-body);color:var(--wc3-text-dim);font-style:italic;font-size:.95rem;margin-top:.15rem}.gold-divider{height:1px;background:linear-gradient(90deg,transparent,var(--wc3-gold-dim),transparent);margin:.75rem 0;border:none}.tab-bar{display:flex;gap:.5rem;padding:.35rem 1.5rem;border-bottom:1px solid var(--wc3-border-dim);flex-shrink:0}.tab-btn-wrap{height:32px;min-width:120px}.tab-trigger.active{filter:brightness(1.3)}.msg{max-width:85%;padding:.85rem 1.15rem;border-radius:6px;line-height:1.65;font-size:1rem;border:1px solid transparent;animation:fadeSlide .25s ease}@keyframes fadeSlide{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.msg.user{align-self:flex-end;background:linear-gradient(135deg,#8c282859,#5a191940);border-color:#c850504d;color:var(--wc3-text-light)}.msg.assistant{align-self:flex-start;background:linear-gradient(135deg,#141e4173,#0f14324d);border-color:#6482c840;color:var(--wc3-text-light)}.msg h1,.msg h2,.msg h3,.msg h4{font-family:var(--font-heading);color:var(--wc3-gold);margin:.75rem 0 .35rem}.msg h1{font-size:1.3rem}.msg h2{font-size:1.15rem}.msg h3{font-size:1.05rem}.msg p{margin:.4rem 0}.msg strong{color:var(--wc3-gold-bright)}.msg em{font-style:italic}.msg ul,.msg ol{padding-left:1.25rem;margin:.4rem 0}.msg li{margin:.2rem 0}.msg code{background:#c8aa6e14;border:1px solid var(--wc3-border-dim);padding:.1rem .4rem;border-radius:3px;font-size:.9em;color:var(--wc3-gold)}.msg pre{background:#0000004d;border:1px solid var(--wc3-border-dim);border-radius:4px;padding:.75rem;overflow-x:auto;margin:.5rem 0}.msg pre code{background:none;border:none;padding:0}.msg blockquote{border-left:3px solid var(--wc3-gold-dim);padding-left:.75rem;margin:.5rem 0;color:var(--wc3-text);font-style:italic}.msg hr{border:none;height:1px;background:linear-gradient(90deg,transparent,var(--wc3-gold-dim),transparent);margin:.75rem 0}.msg-meta{font-size:.8rem;color:var(--wc3-text-dim);font-style:italic;margin-top:.4rem}.wc3-select{width:100%;padding:.5rem .75rem;font-family:var(--font-body);font-size:.95rem;color:var(--wc3-text-light);background:linear-gradient(180deg,#1a1a32,#13132a);border:1px solid var(--wc3-gold-dim);border-radius:2px;outline:none;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23c8aa6e' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right .75rem center}.wc3-select:hover{border-color:var(--wc3-gold)}.wc3-select:focus{border-color:var(--wc3-gold);box-shadow:0 0 8px #c8aa6e33}.wc3-select option{background:var(--wc3-bg-panel);color:var(--wc3-text)}.wc3-input{width:100%;padding:.6rem .85rem;font-family:var(--font-body);font-size:1rem;color:var(--wc3-text-light);background:linear-gradient(180deg,#12122a,#0f0f24);border:1px solid var(--wc3-border);border-radius:2px;outline:none;box-shadow:inset 0 2px 6px #0000004d;transition:border-color .15s}.wc3-input:focus{border-color:var(--wc3-gold);box-shadow:inset 0 2px 6px #0000004d,0 0 8px #c8aa6e26}.wc3-input::placeholder{color:var(--wc3-text-muted)}.wc3-checkbox{display:flex;align-items:center;gap:.5rem;cursor:pointer;font-family:var(--font-body);color:var(--wc3-text);font-size:.95rem}.wc3-checkbox input[type=checkbox]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;background:#13132a;border:1px solid var(--wc3-gold-dim);border-radius:2px;cursor:pointer;position:relative}.wc3-checkbox input[type=checkbox]:checked{border-color:var(--wc3-gold)}.wc3-checkbox input[type=checkbox]:checked:after{content:"✓";position:absolute;top:-1px;left:2px;color:var(--wc3-gold-bright);font-size:14px;font-weight:700}.loading{display:flex;align-items:center;gap:.6rem;padding:.75rem 1rem;color:var(--wc3-gold);font-family:var(--font-heading);font-size:.85rem;font-style:italic;animation:fadeSlide .3s ease}.dot-pulse{display:inline-flex;gap:4px}.dot-pulse span{width:6px;height:6px;border-radius:50%;background:var(--wc3-gold);animation:pulse 1.2s infinite ease-in-out}.dot-pulse span:nth-child(2){animation-delay:.2s}.dot-pulse span:nth-child(3){animation-delay:.4s}@keyframes pulse{0%,80%,to{opacity:.2;transform:scale(.8)}40%{opacity:1;transform:scale(1.1)}}.error-msg{background:#b4282826;border:1px solid #cc3333;color:#f88;padding:.6rem .85rem;border-radius:4px;font-size:.9rem}.resource-bar{background:linear-gradient(180deg,#1a1a30,#112);border:1px solid var(--wc3-border);border-radius:3px;padding:.45rem .75rem;text-align:center;font-family:var(--font-heading);font-size:.8rem;color:var(--wc3-gold);letter-spacing:1px}.sidebar h2{font-family:var(--font-heading);color:var(--wc3-gold);font-size:.85rem;letter-spacing:2px;text-transform:uppercase;text-shadow:0 0 8px rgba(200,170,110,.2);padding-bottom:.4rem;border-bottom:1px solid var(--wc3-border-dim);margin-top:.75rem;margin-bottom:.5rem}.sidebar h2:first-child{margin-top:0}.model-caption{font-size:.85rem;color:var(--wc3-text-dim);font-style:italic;margin-top:.25rem}.sidebar-footer{margin-top:auto;padding-top:.75rem;text-align:center;font-size:.75rem;color:var(--wc3-text-muted);font-style:italic;line-height:1.6}.chat-input-bar{display:flex;gap:.5rem;padding:.75rem 1.5rem;background:linear-gradient(180deg,var(--wc3-bg-panel),var(--wc3-bg));border-top:1px solid var(--wc3-border);flex-shrink:0;align-items:center}.chat-input-bar .wc3-input{flex:1}.chat-send-btn{height:38px;min-width:100px}.citations-toggle{font-family:var(--font-heading);font-size:.8rem;color:var(--wc3-gold-dim);background:none;border:1px solid var(--wc3-border-dim);border-radius:3px;padding:.35rem .75rem;cursor:pointer;margin-top:.5rem;transition:all .15s}.citations-toggle:hover{border-color:var(--wc3-gold);color:var(--wc3-gold)}.citations-panel{margin-top:.5rem;border:1px solid var(--wc3-border-dim);border-radius:4px;background:#0003;padding:.75rem;animation:fadeSlide .2s ease}.citation-item{padding:.5rem 0;border-bottom:1px solid var(--wc3-border-dim)}.citation-item:last-child{border-bottom:none}.citation-header{font-family:var(--font-heading);font-size:.8rem;color:var(--wc3-gold);margin-bottom:.25rem;display:flex;align-items:center;flex-wrap:wrap;gap:.25rem}.citation-text{font-size:.85rem;color:var(--wc3-text);font-style:italic;border-left:2px solid var(--wc3-gold-dim);padding-left:.6rem;margin-top:.25rem;line-height:1.5}.image-tab{flex:1;overflow-y:auto;padding:1.5rem}.upload-zone{border:2px dashed var(--wc3-border);border-radius:6px;padding:2rem;text-align:center;cursor:pointer;transition:all .15s;background:#00000026;margin-bottom:1rem}.upload-zone:hover{border-color:var(--wc3-gold);background:#c8aa6e08}.upload-zone input[type=file]{display:none}.upload-zone .icon{font-size:2.5rem;margin-bottom:.5rem}.upload-zone p{color:var(--wc3-text-dim);font-size:.95rem}.image-preview{max-width:100%;max-height:300px;border:1px solid var(--wc3-border);border-radius:4px;margin-bottom:1rem}.analysis-result{background:linear-gradient(135deg,#141e4173,#0f14324d);border:1px solid rgba(100,130,200,.25);border-radius:6px;padding:1rem 1.25rem;line-height:1.65}.loading-splash{width:100vw;height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;background:radial-gradient(ellipse at center,#12122a,#0a0a16,#050510);color:var(--wc3-gold, #c8aa6e)}.loading-splash__icon{font-size:3rem;margin-bottom:1rem;animation:pulse 1.5s ease-in-out infinite}.loading-splash__text{font-family:var(--font-heading, "Cinzel", serif);font-size:1rem;letter-spacing:3px;text-transform:uppercase}@keyframes pulse{0%,to{opacity:.5;transform:scale(1)}50%{opacity:1;transform:scale(1.1)}}.user-badge{display:flex;align-items:center;gap:.6rem;padding:.4rem 0}.user-avatar{width:32px;height:32px;border-radius:50%;border:1.5px solid var(--wc3-gold-dim, #8b7b4f);flex-shrink:0}.user-info{display:flex;flex-direction:column;min-width:0}.user-name{font-family:var(--font-heading, "Cinzel", serif);color:var(--wc3-gold, #c8aa6e);font-size:.75rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.logout-btn{background:none;border:none;color:var(--wc3-text-dim, #7a6e5a);font-size:.65rem;cursor:pointer;text-align:left;padding:0;font-family:var(--font-body, "Crimson Text", serif);text-decoration:underline;text-underline-offset:2px}.logout-btn:hover{color:var(--wc3-gold, #c8aa6e)}@media(max-width:768px){.sidebar{display:none}:root{--sidebar-w: 0px}.header-frame h1{font-size:1.4rem}}
|
|
|
|
|
|
backend/static/assets/index-CfN_TOmj.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@charset "UTF-8";.btn{width:100%;height:100%;position:relative;background:var(--v854fa1ce) no-repeat;background-size:100% 100%;color:var(--v548a9e4c);-webkit-user-select:none;user-select:none;-webkit-mask-image:var(--v854fa1ce);-webkit-mask-size:100% 100%}.btn__capt{width:100%;position:absolute;text-align:center;bottom:0}.btn--d{font-size:14px;letter-spacing:.5px;line-height:180%}.btn--s{height:32px;line-height:32px;min-width:100px}.btn--m{height:48px;line-height:48px;min-width:200px}*:not([disabled])+.btn:hover:before{content:"";position:absolute;width:100%;height:100%;background:var(--ba36690e) no-repeat;background-size:100% 100%;left:0;top:0;mix-blend-mode:screen}*:not([disabled])+.btn:active{background-image:var(--v4e4c04fa)}*:not([disabled])+.btn:active .btn__capt{left:3px;bottom:-2px}[disabled]+.btn{background-image:var(--v512606b8);pointer-events:all!important;color:#646464}.cursored,.cursored *{cursor:none}.cursor{position:relative;width:32px;height:32px;background:var(--v7b062dde) no-repeat}.cursor__container{top:0;left:0;position:fixed;z-index:99;pointer-events:none}.cursor--default-active{animation:cursor-active .5s steps(8) infinite}.cursor--pointer{background-position:-32px -96px}.cursor--pointer-active{animation:cursor-pointer .5s steps(8) infinite}.cursor--pointer-denied{background-position:-64px -96px}.cursor--hold{background-position:-128px -96px}.cursor--arrow-top{animation:cursor-arrow .1s steps(3) infinite;transform:rotate(-90deg)}.cursor--arrow-right{animation:cursor-arrow .1s steps(3) infinite}.cursor--arrow-bottom{animation:cursor-arrow .1s steps(3) infinite;transform:rotate(90deg)}.cursor--arrow-left{animation:cursor-arrow .1s steps(3) infinite;transform:rotate(-180deg)}@keyframes cursor-active{0%{background-position:0 0}to{background-position:-256px 0}}@keyframes cursor-pointer{0%{background-position:0 -64px}to{background-position:-256px -64px}}@keyframes cursor-arrow{0%{background-position:-160px -96px}to{background-position:-256px -96px}}.lang-switch[data-v-7718b16c]{display:flex;gap:.4rem}.lang-switch-btn[data-v-7718b16c]{flex:1;background:linear-gradient(180deg,#1a1a32,#13132a);border:1px solid var(--wc3-border-dim, #3a2e1e);border-radius:3px;color:var(--wc3-text-dim, #7a6e5a);font-family:var(--font-body, "Crimson Text", serif);font-size:.85rem;padding:.4rem .5rem;cursor:pointer;transition:all .15s}.lang-switch-btn[data-v-7718b16c]:hover{border-color:var(--wc3-gold-dim, #8b7b4f);color:var(--wc3-text, #d4c4a0)}.lang-switch-btn.active[data-v-7718b16c]{border-color:var(--wc3-gold, #c8aa6e);color:var(--wc3-gold, #c8aa6e);background:#c8aa6e14}.chat-area[data-v-5539d8f7]{flex:1;overflow:hidden;display:flex;flex-direction:column}.messages-container[data-v-5539d8f7]{flex:1;overflow-y:auto;padding:1rem 1.5rem;display:flex;flex-direction:column;gap:.75rem}.login-page{width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;background:radial-gradient(ellipse at center,#12122a,#0a0a16,#050510)}.login-card{width:100%;max-width:480px;padding:2rem;position:relative}.lang-toggle{display:flex;align-items:center;justify-content:center;gap:.25rem;margin-bottom:.75rem}.lang-btn{background:none;border:1px solid transparent;color:var(--wc3-text-dim, #7a6e5a);font-family:var(--font-heading, "Cinzel", serif);font-size:.8rem;letter-spacing:1.5px;padding:.3rem .75rem;cursor:pointer;border-radius:3px;transition:all .15s}.lang-btn:hover{color:var(--wc3-gold, #c8aa6e);border-color:var(--wc3-gold-dim, #8b7b4f)}.lang-btn.active{color:var(--wc3-gold-bright, #f0d060);border-color:var(--wc3-gold, #c8aa6e);background:#c8aa6e14;text-shadow:0 0 8px rgba(200,170,110,.3)}.lang-sep{color:var(--wc3-border, #5a4a2a);font-size:.8rem}.login-frame{background:linear-gradient(180deg,#13132a,#0d0d1f);border:2px solid var(--wc3-gold-dim, #8b7b4f);border-radius:6px;padding:2.5rem 2rem;text-align:center;box-shadow:0 0 40px #c8aa6e14,inset 0 1px #c8aa6e1a}.login-icon{font-size:3rem;margin-bottom:.75rem;filter:drop-shadow(0 0 10px rgba(200,170,110,.4))}.login-frame h1{font-family:var(--font-display, "Cinzel Decorative", serif);font-size:1.5rem;font-weight:900;background:linear-gradient(180deg,#f0d060,#c8aa6e,#8b7b4f);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;letter-spacing:1px;margin-bottom:.25rem;word-break:keep-all}.login-frame .subtitle{font-family:var(--font-body, "Crimson Text", serif);color:var(--wc3-text-dim, #7a6e5a);font-style:italic;font-size:.95rem}.login-prompt{font-family:var(--font-heading, "Cinzel", serif);color:var(--wc3-gold, #c8aa6e);font-size:.9rem;letter-spacing:1.5px;text-transform:uppercase;margin-bottom:1.25rem}.g-btn-container{display:flex;justify-content:center;min-height:44px;margin-bottom:.5rem}.login-loading{color:var(--wc3-gold, #c8aa6e);font-size:.85rem;margin-top:.75rem;animation:pulse 1.5s ease-in-out infinite}.login-error{color:#f66;font-size:.85rem;margin-top:.75rem}.iframe-hint{color:var(--wc3-text-dim, #7a6e5a);font-size:.78rem;margin-top:1rem;line-height:1.6}.direct-link{color:var(--wc3-gold, #c8aa6e);text-decoration:underline;text-underline-offset:2px;word-break:break-all}.direct-link:hover{color:var(--wc3-gold-bright, #f0d060)}.login-footer{font-size:.8rem;color:var(--wc3-text-dim, #7a6e5a);font-style:italic;line-height:1.6}.login-footer-dim{color:var(--wc3-text-muted, #555060);font-size:.75rem}:root{--wc3-bg: #0a0a0f;--wc3-bg-panel: #0f0f1a;--wc3-gold: #c8aa6e;--wc3-gold-bright: #f0d060;--wc3-gold-dim: #8b7b4f;--wc3-text: #d4c4a0;--wc3-text-light: #f0e6d0;--wc3-text-dim: #7a6e5a;--wc3-text-muted: #555060;--wc3-border: #5a4a2a;--wc3-border-dim: #3a2e1e;--wc3-red-soft: rgba(140, 40, 40, .25);--wc3-blue-soft: rgba(30, 50, 90, .35);--font-heading: "Cinzel", serif;--font-display: "Cinzel Decorative", "Cinzel", serif;--font-body: "Crimson Text", serif;--sidebar-w: 270px}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html,body{height:100%;overflow:hidden;background:var(--wc3-bg);color:var(--wc3-text);font-family:var(--font-body);line-height:1.6}#app{height:100vh}::-webkit-scrollbar{width:8px}::-webkit-scrollbar-track{background:#0000004d}::-webkit-scrollbar-thumb{background:linear-gradient(180deg,var(--wc3-gold-dim),#4a3a20);border-radius:2px}::-webkit-scrollbar-thumb:hover{background:var(--wc3-gold)}.app-shell{display:flex;height:100vh;overflow:hidden}.sidebar{width:var(--sidebar-w);min-width:var(--sidebar-w);background:linear-gradient(180deg,#0f0f22,#0a0a18);border-right:2px solid var(--wc3-border);display:flex;flex-direction:column;overflow-y:auto;padding:1rem;box-shadow:2px 0 20px #00000080}.main-content{flex:1;display:flex;flex-direction:column;overflow:hidden;background:radial-gradient(ellipse at top center,#12122a,#0a0a16 60%,#070710)}.app-header{text-align:center;padding:1.25rem 1rem .5rem;flex-shrink:0}.header-frame h1{font-family:var(--font-display);font-size:2rem;font-weight:900;background:linear-gradient(180deg,#f0d060,#c8aa6e,#8b7b4f);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;letter-spacing:3px;filter:drop-shadow(0 2px 4px rgba(0,0,0,.8))}.header-frame .subtitle{font-family:var(--font-body);color:var(--wc3-text-dim);font-style:italic;font-size:.95rem;margin-top:.15rem}.gold-divider{height:1px;background:linear-gradient(90deg,transparent,var(--wc3-gold-dim),transparent);margin:.75rem 0;border:none}.tab-bar{display:flex;gap:.5rem;padding:.35rem 1.5rem;border-bottom:1px solid var(--wc3-border-dim);flex-shrink:0}.tab-btn-wrap{height:32px;min-width:120px}.tab-trigger.active{filter:brightness(1.3)}.msg{max-width:85%;padding:.85rem 1.15rem;border-radius:6px;line-height:1.65;font-size:1rem;border:1px solid transparent;animation:fadeSlide .25s ease}@keyframes fadeSlide{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.msg.user{align-self:flex-end;background:linear-gradient(135deg,#8c282859,#5a191940);border-color:#c850504d;color:var(--wc3-text-light)}.msg.assistant{align-self:flex-start;background:linear-gradient(135deg,#141e4173,#0f14324d);border-color:#6482c840;color:var(--wc3-text-light)}.msg h1,.msg h2,.msg h3,.msg h4{font-family:var(--font-heading);color:var(--wc3-gold);margin:.75rem 0 .35rem}.msg h1{font-size:1.3rem}.msg h2{font-size:1.15rem}.msg h3{font-size:1.05rem}.msg p{margin:.4rem 0}.msg strong{color:var(--wc3-gold-bright)}.msg em{font-style:italic}.msg ul,.msg ol{padding-left:1.25rem;margin:.4rem 0}.msg li{margin:.2rem 0}.msg code{background:#c8aa6e14;border:1px solid var(--wc3-border-dim);padding:.1rem .4rem;border-radius:3px;font-size:.9em;color:var(--wc3-gold)}.msg pre{background:#0000004d;border:1px solid var(--wc3-border-dim);border-radius:4px;padding:.75rem;overflow-x:auto;margin:.5rem 0}.msg pre code{background:none;border:none;padding:0}.msg blockquote{border-left:3px solid var(--wc3-gold-dim);padding-left:.75rem;margin:.5rem 0;color:var(--wc3-text);font-style:italic}.msg hr{border:none;height:1px;background:linear-gradient(90deg,transparent,var(--wc3-gold-dim),transparent);margin:.75rem 0}.msg-meta{font-size:.8rem;color:var(--wc3-text-dim);font-style:italic;margin-top:.4rem}.wc3-select{width:100%;padding:.5rem .75rem;font-family:var(--font-body);font-size:.95rem;color:var(--wc3-text-light);background:linear-gradient(180deg,#1a1a32,#13132a);border:1px solid var(--wc3-gold-dim);border-radius:2px;outline:none;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23c8aa6e' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right .75rem center}.wc3-select:hover{border-color:var(--wc3-gold)}.wc3-select:focus{border-color:var(--wc3-gold);box-shadow:0 0 8px #c8aa6e33}.wc3-select option{background:var(--wc3-bg-panel);color:var(--wc3-text)}.wc3-input{width:100%;padding:.6rem .85rem;font-family:var(--font-body);font-size:1rem;color:var(--wc3-text-light);background:linear-gradient(180deg,#12122a,#0f0f24);border:1px solid var(--wc3-border);border-radius:2px;outline:none;box-shadow:inset 0 2px 6px #0000004d;transition:border-color .15s}.wc3-input:focus{border-color:var(--wc3-gold);box-shadow:inset 0 2px 6px #0000004d,0 0 8px #c8aa6e26}.wc3-input::placeholder{color:var(--wc3-text-muted)}.wc3-checkbox{display:flex;align-items:center;gap:.5rem;cursor:pointer;font-family:var(--font-body);color:var(--wc3-text);font-size:.95rem}.wc3-checkbox input[type=checkbox]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;background:#13132a;border:1px solid var(--wc3-gold-dim);border-radius:2px;cursor:pointer;position:relative}.wc3-checkbox input[type=checkbox]:checked{border-color:var(--wc3-gold)}.wc3-checkbox input[type=checkbox]:checked:after{content:"✓";position:absolute;top:-1px;left:2px;color:var(--wc3-gold-bright);font-size:14px;font-weight:700}.loading{display:flex;align-items:center;gap:.6rem;padding:.75rem 1rem;color:var(--wc3-gold);font-family:var(--font-heading);font-size:.85rem;font-style:italic;animation:fadeSlide .3s ease}.dot-pulse{display:inline-flex;gap:4px}.dot-pulse span{width:6px;height:6px;border-radius:50%;background:var(--wc3-gold);animation:pulse 1.2s infinite ease-in-out}.dot-pulse span:nth-child(2){animation-delay:.2s}.dot-pulse span:nth-child(3){animation-delay:.4s}@keyframes pulse{0%,80%,to{opacity:.2;transform:scale(.8)}40%{opacity:1;transform:scale(1.1)}}.error-msg{background:#b4282826;border:1px solid #cc3333;color:#f88;padding:.6rem .85rem;border-radius:4px;font-size:.9rem}.resource-bar{background:linear-gradient(180deg,#1a1a30,#112);border:1px solid var(--wc3-border);border-radius:3px;padding:.45rem .75rem;text-align:center;font-family:var(--font-heading);font-size:.8rem;color:var(--wc3-gold);letter-spacing:1px}.sidebar h2{font-family:var(--font-heading);color:var(--wc3-gold);font-size:.85rem;letter-spacing:2px;text-transform:uppercase;text-shadow:0 0 8px rgba(200,170,110,.2);padding-bottom:.4rem;border-bottom:1px solid var(--wc3-border-dim);margin-top:.75rem;margin-bottom:.5rem}.sidebar h2:first-child{margin-top:0}.model-caption{font-size:.85rem;color:var(--wc3-text-dim);font-style:italic;margin-top:.25rem}.sidebar-footer{margin-top:auto;padding-top:.75rem;text-align:center;font-size:.75rem;color:var(--wc3-text-muted);font-style:italic;line-height:1.6}.chat-input-bar{display:flex;gap:.5rem;padding:.75rem 1.5rem;background:linear-gradient(180deg,var(--wc3-bg-panel),var(--wc3-bg));border-top:1px solid var(--wc3-border);flex-shrink:0;align-items:center}.chat-input-bar .wc3-input{flex:1}.chat-send-btn{height:38px;min-width:100px}.citations-toggle{font-family:var(--font-heading);font-size:.8rem;color:var(--wc3-gold-dim);background:none;border:1px solid var(--wc3-border-dim);border-radius:3px;padding:.35rem .75rem;cursor:pointer;margin-top:.5rem;transition:all .15s}.citations-toggle:hover{border-color:var(--wc3-gold);color:var(--wc3-gold)}.citations-panel{margin-top:.5rem;border:1px solid var(--wc3-border-dim);border-radius:4px;background:#0003;padding:.75rem;animation:fadeSlide .2s ease}.citation-item{padding:.5rem 0;border-bottom:1px solid var(--wc3-border-dim)}.citation-item:last-child{border-bottom:none}.citation-header{font-family:var(--font-heading);font-size:.8rem;color:var(--wc3-gold);margin-bottom:.25rem;display:flex;align-items:center;flex-wrap:wrap;gap:.25rem}.citation-text{font-size:.85rem;color:var(--wc3-text);font-style:italic;border-left:2px solid var(--wc3-gold-dim);padding-left:.6rem;margin-top:.25rem;line-height:1.5}.image-tab{flex:1;overflow-y:auto;padding:1.5rem}.upload-zone{border:2px dashed var(--wc3-border);border-radius:6px;padding:2rem;text-align:center;cursor:pointer;transition:all .15s;background:#00000026;margin-bottom:1rem}.upload-zone:hover{border-color:var(--wc3-gold);background:#c8aa6e08}.upload-zone input[type=file]{display:none}.upload-zone .icon{font-size:2.5rem;margin-bottom:.5rem}.upload-zone p{color:var(--wc3-text-dim);font-size:.95rem}.image-preview{max-width:100%;max-height:300px;border:1px solid var(--wc3-border);border-radius:4px;margin-bottom:1rem}.analysis-result{background:linear-gradient(135deg,#141e4173,#0f14324d);border:1px solid rgba(100,130,200,.25);border-radius:6px;padding:1rem 1.25rem;line-height:1.65}.loading-splash{width:100vw;height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;background:radial-gradient(ellipse at center,#12122a,#0a0a16,#050510);color:var(--wc3-gold, #c8aa6e)}.loading-splash__icon{font-size:3rem;margin-bottom:1rem;animation:pulse 1.5s ease-in-out infinite}.loading-splash__text{font-family:var(--font-heading, "Cinzel", serif);font-size:1rem;letter-spacing:3px;text-transform:uppercase}@keyframes pulse{0%,to{opacity:.5;transform:scale(1)}50%{opacity:1;transform:scale(1.1)}}.user-badge{display:flex;align-items:center;gap:.6rem;padding:.4rem 0}.user-avatar{width:32px;height:32px;border-radius:50%;border:1.5px solid var(--wc3-gold-dim, #8b7b4f);flex-shrink:0}.user-info{display:flex;flex-direction:column;min-width:0}.user-name{font-family:var(--font-heading, "Cinzel", serif);color:var(--wc3-gold, #c8aa6e);font-size:.75rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.logout-btn{background:none;border:none;color:var(--wc3-text-dim, #7a6e5a);font-size:.65rem;cursor:pointer;text-align:left;padding:0;font-family:var(--font-body, "Crimson Text", serif);text-decoration:underline;text-underline-offset:2px}.logout-btn:hover{color:var(--wc3-gold, #c8aa6e)}@media(max-width:768px){.sidebar{display:none}:root{--sidebar-w: 0px}.header-frame h1{font-size:1.4rem}}
|
backend/static/assets/index-D8b5zKM1.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/static/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=Cinzel+Decorative:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
| 11 |
-
<script type="module" crossorigin src="/assets/index-
|
| 12 |
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
<div id="app"></div>
|
|
|
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=Cinzel+Decorative:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
| 11 |
+
<script type="module" crossorigin src="/assets/index-BAvZzacE.js"></script>
|
| 12 |
+
<link rel="stylesheet" crossorigin href="/assets/index-CfN_TOmj.css">
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
<div id="app"></div>
|
frontend/src/App.vue
CHANGED
|
@@ -5,6 +5,8 @@ import {
|
|
| 5 |
getModels, getStats, getAuthConfig, loginWithGoogle, getMe,
|
| 6 |
setToken, clearToken, getToken,
|
| 7 |
} from './api'
|
|
|
|
|
|
|
| 8 |
import ConfigProvider from './wc3/ConfigProvider'
|
| 9 |
import Wc3Button from './wc3/base/Button.vue'
|
| 10 |
import Wc3Cursor from './wc3/base/Cursor.vue'
|
|
@@ -14,6 +16,14 @@ import ChatView from './components/ChatView.vue'
|
|
| 14 |
import ImageView from './components/ImageView.vue'
|
| 15 |
import LoginPage from './components/LoginPage.vue'
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
// Auth state
|
| 18 |
const authLoading = ref(true)
|
| 19 |
const authEnabled = ref(false)
|
|
@@ -99,14 +109,16 @@ function clearChat() {
|
|
| 99 |
<!-- Loading splash -->
|
| 100 |
<div v-if="authLoading" class="loading-splash">
|
| 101 |
<div class="loading-splash__icon">⚔️</div>
|
| 102 |
-
<div class="loading-splash__text">
|
| 103 |
</div>
|
| 104 |
|
| 105 |
<!-- Login page (when auth is required and user not logged in) -->
|
| 106 |
<LoginPage
|
| 107 |
v-else-if="authEnabled && !isLoggedIn"
|
| 108 |
:googleClientId="googleClientId"
|
|
|
|
| 109 |
@google-login="handleGoogleLogin"
|
|
|
|
| 110 |
/>
|
| 111 |
|
| 112 |
<!-- Main app -->
|
|
@@ -122,8 +134,10 @@ function clearChat() {
|
|
| 122 |
:models="models"
|
| 123 |
:stats="stats"
|
| 124 |
:user="currentUser"
|
|
|
|
| 125 |
@clear-chat="clearChat"
|
| 126 |
@logout="handleLogout"
|
|
|
|
| 127 |
/>
|
| 128 |
</aside>
|
| 129 |
|
|
@@ -132,8 +146,8 @@ function clearChat() {
|
|
| 132 |
<!-- Header Bar (styled like WC3 top bar) -->
|
| 133 |
<header class="app-header">
|
| 134 |
<div class="header-frame">
|
| 135 |
-
<h1>
|
| 136 |
-
<p class="subtitle">
|
| 137 |
</div>
|
| 138 |
</header>
|
| 139 |
|
|
@@ -145,7 +159,7 @@ function clearChat() {
|
|
| 145 |
:class="['tab-trigger', { active: activeTab === 'chat' }]"
|
| 146 |
@click="activeTab = 'chat'"
|
| 147 |
>
|
| 148 |
-
|
| 149 |
</Wc3Button>
|
| 150 |
</div>
|
| 151 |
<div class="tab-btn-wrap">
|
|
@@ -154,7 +168,7 @@ function clearChat() {
|
|
| 154 |
:class="['tab-trigger', { active: activeTab === 'image' }]"
|
| 155 |
@click="activeTab = 'image'"
|
| 156 |
>
|
| 157 |
-
|
| 158 |
</Wc3Button>
|
| 159 |
</div>
|
| 160 |
</nav>
|
|
@@ -165,11 +179,13 @@ function clearChat() {
|
|
| 165 |
v-model:history="chatHistory"
|
| 166 |
:model="selectedModel"
|
| 167 |
:showCitations="showCitations"
|
|
|
|
| 168 |
/>
|
| 169 |
<ImageView
|
| 170 |
v-else
|
| 171 |
:model="selectedModel"
|
| 172 |
:showCitations="showCitations"
|
|
|
|
| 173 |
/>
|
| 174 |
</main>
|
| 175 |
</div>
|
|
|
|
| 5 |
getModels, getStats, getAuthConfig, loginWithGoogle, getMe,
|
| 6 |
setToken, clearToken, getToken,
|
| 7 |
} from './api'
|
| 8 |
+
import { t, getStoredLang, storeLang } from './i18n'
|
| 9 |
+
import type { Lang } from './i18n'
|
| 10 |
import ConfigProvider from './wc3/ConfigProvider'
|
| 11 |
import Wc3Button from './wc3/base/Button.vue'
|
| 12 |
import Wc3Cursor from './wc3/base/Cursor.vue'
|
|
|
|
| 16 |
import ImageView from './components/ImageView.vue'
|
| 17 |
import LoginPage from './components/LoginPage.vue'
|
| 18 |
|
| 19 |
+
// Language state
|
| 20 |
+
const lang = ref<Lang>(getStoredLang())
|
| 21 |
+
|
| 22 |
+
function setLang(newLang: Lang) {
|
| 23 |
+
lang.value = newLang
|
| 24 |
+
storeLang(newLang)
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
// Auth state
|
| 28 |
const authLoading = ref(true)
|
| 29 |
const authEnabled = ref(false)
|
|
|
|
| 109 |
<!-- Loading splash -->
|
| 110 |
<div v-if="authLoading" class="loading-splash">
|
| 111 |
<div class="loading-splash__icon">⚔️</div>
|
| 112 |
+
<div class="loading-splash__text">{{ t('app.loading', lang) }}</div>
|
| 113 |
</div>
|
| 114 |
|
| 115 |
<!-- Login page (when auth is required and user not logged in) -->
|
| 116 |
<LoginPage
|
| 117 |
v-else-if="authEnabled && !isLoggedIn"
|
| 118 |
:googleClientId="googleClientId"
|
| 119 |
+
:lang="lang"
|
| 120 |
@google-login="handleGoogleLogin"
|
| 121 |
+
@update:lang="setLang"
|
| 122 |
/>
|
| 123 |
|
| 124 |
<!-- Main app -->
|
|
|
|
| 134 |
:models="models"
|
| 135 |
:stats="stats"
|
| 136 |
:user="currentUser"
|
| 137 |
+
:lang="lang"
|
| 138 |
@clear-chat="clearChat"
|
| 139 |
@logout="handleLogout"
|
| 140 |
+
@update:lang="setLang"
|
| 141 |
/>
|
| 142 |
</aside>
|
| 143 |
|
|
|
|
| 146 |
<!-- Header Bar (styled like WC3 top bar) -->
|
| 147 |
<header class="app-header">
|
| 148 |
<div class="header-frame">
|
| 149 |
+
<h1>{{ t('app.title', lang) }}</h1>
|
| 150 |
+
<p class="subtitle">{{ t('app.subtitle', lang) }}</p>
|
| 151 |
</div>
|
| 152 |
</header>
|
| 153 |
|
|
|
|
| 159 |
:class="['tab-trigger', { active: activeTab === 'chat' }]"
|
| 160 |
@click="activeTab = 'chat'"
|
| 161 |
>
|
| 162 |
+
{{ t('tab.chat', lang) }}
|
| 163 |
</Wc3Button>
|
| 164 |
</div>
|
| 165 |
<div class="tab-btn-wrap">
|
|
|
|
| 168 |
:class="['tab-trigger', { active: activeTab === 'image' }]"
|
| 169 |
@click="activeTab = 'image'"
|
| 170 |
>
|
| 171 |
+
{{ t('tab.image', lang) }}
|
| 172 |
</Wc3Button>
|
| 173 |
</div>
|
| 174 |
</nav>
|
|
|
|
| 179 |
v-model:history="chatHistory"
|
| 180 |
:model="selectedModel"
|
| 181 |
:showCitations="showCitations"
|
| 182 |
+
:lang="lang"
|
| 183 |
/>
|
| 184 |
<ImageView
|
| 185 |
v-else
|
| 186 |
:model="selectedModel"
|
| 187 |
:showCitations="showCitations"
|
| 188 |
+
:lang="lang"
|
| 189 |
/>
|
| 190 |
</main>
|
| 191 |
</div>
|
frontend/src/api.ts
CHANGED
|
@@ -94,12 +94,14 @@ export async function sendChat(
|
|
| 94 |
message: string,
|
| 95 |
model: string,
|
| 96 |
history: ChatMessage[],
|
|
|
|
| 97 |
): Promise<ChatResponse> {
|
| 98 |
return request('/chat', {
|
| 99 |
method: 'POST',
|
| 100 |
body: JSON.stringify({
|
| 101 |
message,
|
| 102 |
model,
|
|
|
|
| 103 |
history: history.map((m) => ({ role: m.role, content: m.content })),
|
| 104 |
}),
|
| 105 |
})
|
|
|
|
| 94 |
message: string,
|
| 95 |
model: string,
|
| 96 |
history: ChatMessage[],
|
| 97 |
+
lang: string = 'hr',
|
| 98 |
): Promise<ChatResponse> {
|
| 99 |
return request('/chat', {
|
| 100 |
method: 'POST',
|
| 101 |
body: JSON.stringify({
|
| 102 |
message,
|
| 103 |
model,
|
| 104 |
+
lang,
|
| 105 |
history: history.map((m) => ({ role: m.role, content: m.content })),
|
| 106 |
}),
|
| 107 |
})
|
frontend/src/components/ChatView.vue
CHANGED
|
@@ -2,6 +2,8 @@
|
|
| 2 |
import { ref, nextTick, watch } from 'vue'
|
| 3 |
import { marked } from 'marked'
|
| 4 |
import type { ChatMessage } from '../types'
|
|
|
|
|
|
|
| 5 |
import { sendChat } from '../api'
|
| 6 |
import Wc3Button from '../wc3/base/Button.vue'
|
| 7 |
import CitationPanel from './CitationPanel.vue'
|
|
@@ -9,6 +11,7 @@ import CitationPanel from './CitationPanel.vue'
|
|
| 9 |
const props = defineProps<{
|
| 10 |
model: string
|
| 11 |
showCitations: boolean
|
|
|
|
| 12 |
}>()
|
| 13 |
|
| 14 |
const history = defineModel<ChatMessage[]>('history', { required: true })
|
|
@@ -45,7 +48,7 @@ async function handleSend() {
|
|
| 45 |
|
| 46 |
isLoading.value = true
|
| 47 |
try {
|
| 48 |
-
const res = await sendChat(text, props.model, history.value)
|
| 49 |
history.value.push({
|
| 50 |
role: 'assistant',
|
| 51 |
content: res.reply,
|
|
@@ -53,7 +56,7 @@ async function handleSend() {
|
|
| 53 |
model_used: res.model_used,
|
| 54 |
})
|
| 55 |
} catch (e: any) {
|
| 56 |
-
error.value = e.message || '
|
| 57 |
} finally {
|
| 58 |
isLoading.value = false
|
| 59 |
await scrollToBottom()
|
|
@@ -76,10 +79,10 @@ function handleKeydown(e: KeyboardEvent) {
|
|
| 76 |
<div v-if="history.length === 0" style="text-align: center; margin-top: 3rem;">
|
| 77 |
<div style="font-size: 3rem; margin-bottom: 0.75rem;">📜</div>
|
| 78 |
<p style="font-family: var(--font-heading); color: var(--wc3-gold); font-size: 1rem;">
|
| 79 |
-
|
| 80 |
</p>
|
| 81 |
<p style="color: var(--wc3-text-dim); font-size: 0.9rem; max-width: 400px; margin: 0.5rem auto;">
|
| 82 |
-
|
| 83 |
</p>
|
| 84 |
</div>
|
| 85 |
|
|
@@ -92,12 +95,13 @@ function handleKeydown(e: KeyboardEvent) {
|
|
| 92 |
<CitationPanel
|
| 93 |
v-if="msg.role === 'assistant' && showCitations && msg.citations?.length"
|
| 94 |
:citations="msg.citations"
|
|
|
|
| 95 |
/>
|
| 96 |
</div>
|
| 97 |
</template>
|
| 98 |
|
| 99 |
<div v-if="isLoading" class="loading">
|
| 100 |
-
<span>
|
| 101 |
<span class="dot-pulse"><span></span><span></span><span></span></span>
|
| 102 |
</div>
|
| 103 |
|
|
@@ -110,12 +114,12 @@ function handleKeydown(e: KeyboardEvent) {
|
|
| 110 |
class="wc3-input"
|
| 111 |
v-model="inputText"
|
| 112 |
@keydown="handleKeydown"
|
| 113 |
-
placeholder="
|
| 114 |
:disabled="isLoading"
|
| 115 |
/>
|
| 116 |
<div class="chat-send-btn">
|
| 117 |
<Wc3Button size="s" @click="handleSend" :disabled="isLoading || !inputText.trim()">
|
| 118 |
-
|
| 119 |
</Wc3Button>
|
| 120 |
</div>
|
| 121 |
</div>
|
|
|
|
| 2 |
import { ref, nextTick, watch } from 'vue'
|
| 3 |
import { marked } from 'marked'
|
| 4 |
import type { ChatMessage } from '../types'
|
| 5 |
+
import type { Lang } from '../i18n'
|
| 6 |
+
import { t } from '../i18n'
|
| 7 |
import { sendChat } from '../api'
|
| 8 |
import Wc3Button from '../wc3/base/Button.vue'
|
| 9 |
import CitationPanel from './CitationPanel.vue'
|
|
|
|
| 11 |
const props = defineProps<{
|
| 12 |
model: string
|
| 13 |
showCitations: boolean
|
| 14 |
+
lang: Lang
|
| 15 |
}>()
|
| 16 |
|
| 17 |
const history = defineModel<ChatMessage[]>('history', { required: true })
|
|
|
|
| 48 |
|
| 49 |
isLoading.value = true
|
| 50 |
try {
|
| 51 |
+
const res = await sendChat(text, props.model, history.value, props.lang)
|
| 52 |
history.value.push({
|
| 53 |
role: 'assistant',
|
| 54 |
content: res.reply,
|
|
|
|
| 56 |
model_used: res.model_used,
|
| 57 |
})
|
| 58 |
} catch (e: any) {
|
| 59 |
+
error.value = e.message || t('chat.error', props.lang)
|
| 60 |
} finally {
|
| 61 |
isLoading.value = false
|
| 62 |
await scrollToBottom()
|
|
|
|
| 79 |
<div v-if="history.length === 0" style="text-align: center; margin-top: 3rem;">
|
| 80 |
<div style="font-size: 3rem; margin-bottom: 0.75rem;">📜</div>
|
| 81 |
<p style="font-family: var(--font-heading); color: var(--wc3-gold); font-size: 1rem;">
|
| 82 |
+
{{ t('chat.welcome', lang) }}
|
| 83 |
</p>
|
| 84 |
<p style="color: var(--wc3-text-dim); font-size: 0.9rem; max-width: 400px; margin: 0.5rem auto;">
|
| 85 |
+
{{ t('chat.welcomeHint', lang) }}
|
| 86 |
</p>
|
| 87 |
</div>
|
| 88 |
|
|
|
|
| 95 |
<CitationPanel
|
| 96 |
v-if="msg.role === 'assistant' && showCitations && msg.citations?.length"
|
| 97 |
:citations="msg.citations"
|
| 98 |
+
:lang="lang"
|
| 99 |
/>
|
| 100 |
</div>
|
| 101 |
</template>
|
| 102 |
|
| 103 |
<div v-if="isLoading" class="loading">
|
| 104 |
+
<span>{{ t('chat.generating', lang) }}</span>
|
| 105 |
<span class="dot-pulse"><span></span><span></span><span></span></span>
|
| 106 |
</div>
|
| 107 |
|
|
|
|
| 114 |
class="wc3-input"
|
| 115 |
v-model="inputText"
|
| 116 |
@keydown="handleKeydown"
|
| 117 |
+
:placeholder="t('chat.placeholder', lang)"
|
| 118 |
:disabled="isLoading"
|
| 119 |
/>
|
| 120 |
<div class="chat-send-btn">
|
| 121 |
<Wc3Button size="s" @click="handleSend" :disabled="isLoading || !inputText.trim()">
|
| 122 |
+
{{ t('chat.send', lang) }}
|
| 123 |
</Wc3Button>
|
| 124 |
</div>
|
| 125 |
</div>
|
frontend/src/components/CitationPanel.vue
CHANGED
|
@@ -1,8 +1,13 @@
|
|
| 1 |
<script setup lang="ts">
|
| 2 |
import { ref } from 'vue'
|
| 3 |
import type { Citation } from '../types'
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
defineProps<{
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
const isOpen = ref(false)
|
| 8 |
|
|
@@ -20,7 +25,7 @@ function scoreBar(score: number): string {
|
|
| 20 |
<template>
|
| 21 |
<div>
|
| 22 |
<button class="citations-toggle" @click="isOpen = !isOpen">
|
| 23 |
-
📜
|
| 24 |
{{ isOpen ? '▲' : '▼' }}
|
| 25 |
</button>
|
| 26 |
|
|
@@ -33,7 +38,7 @@ function scoreBar(score: number): string {
|
|
| 33 |
<div class="citation-header">
|
| 34 |
<span>#{{ c.rank }}</span>
|
| 35 |
<span style="margin-left: 0.75rem;">
|
| 36 |
-
📄 {{ c.source }} —
|
| 37 |
</span>
|
| 38 |
<span style="margin-left: auto; display: inline-flex; align-items: center; gap: 0.3rem;">
|
| 39 |
<span
|
|
|
|
| 1 |
<script setup lang="ts">
|
| 2 |
import { ref } from 'vue'
|
| 3 |
import type { Citation } from '../types'
|
| 4 |
+
import type { Lang } from '../i18n'
|
| 5 |
+
import { t } from '../i18n'
|
| 6 |
|
| 7 |
+
defineProps<{
|
| 8 |
+
citations: Citation[]
|
| 9 |
+
lang: Lang
|
| 10 |
+
}>()
|
| 11 |
|
| 12 |
const isOpen = ref(false)
|
| 13 |
|
|
|
|
| 25 |
<template>
|
| 26 |
<div>
|
| 27 |
<button class="citations-toggle" @click="isOpen = !isOpen">
|
| 28 |
+
📜 {{ t('citations.sources', lang) }} ({{ citations.length }})
|
| 29 |
{{ isOpen ? '▲' : '▼' }}
|
| 30 |
</button>
|
| 31 |
|
|
|
|
| 38 |
<div class="citation-header">
|
| 39 |
<span>#{{ c.rank }}</span>
|
| 40 |
<span style="margin-left: 0.75rem;">
|
| 41 |
+
📄 {{ c.source }} — {{ t('citations.page', lang) }} {{ c.page_num }}
|
| 42 |
</span>
|
| 43 |
<span style="margin-left: auto; display: inline-flex; align-items: center; gap: 0.3rem;">
|
| 44 |
<span
|
frontend/src/components/ImageView.vue
CHANGED
|
@@ -2,6 +2,8 @@
|
|
| 2 |
import { ref } from 'vue'
|
| 3 |
import { marked } from 'marked'
|
| 4 |
import type { Citation } from '../types'
|
|
|
|
|
|
|
| 5 |
import { analyzeImage } from '../api'
|
| 6 |
import Wc3Button from '../wc3/base/Button.vue'
|
| 7 |
import CitationPanel from './CitationPanel.vue'
|
|
@@ -9,6 +11,7 @@ import CitationPanel from './CitationPanel.vue'
|
|
| 9 |
const props = defineProps<{
|
| 10 |
model: string
|
| 11 |
showCitations: boolean
|
|
|
|
| 12 |
}>()
|
| 13 |
|
| 14 |
const selectedFile = ref<File | null>(null)
|
|
@@ -58,7 +61,7 @@ async function handleAnalyze() {
|
|
| 58 |
analysis.value = res.analysis
|
| 59 |
citations.value = res.citations
|
| 60 |
} catch (e: any) {
|
| 61 |
-
error.value = e.message || '
|
| 62 |
} finally {
|
| 63 |
isLoading.value = false
|
| 64 |
}
|
|
@@ -76,9 +79,9 @@ async function handleAnalyze() {
|
|
| 76 |
<input ref="fileInput" type="file" accept="image/*" @change="handleFileSelect" />
|
| 77 |
<div v-if="!imagePreview">
|
| 78 |
<div class="icon">📄</div>
|
| 79 |
-
<p>
|
| 80 |
<p style="font-size: 0.8rem; color: var(--wc3-text-muted);">
|
| 81 |
-
|
| 82 |
</p>
|
| 83 |
</div>
|
| 84 |
<img v-else :src="imagePreview" class="image-preview" alt="Preview" />
|
|
@@ -88,18 +91,18 @@ async function handleAnalyze() {
|
|
| 88 |
<input
|
| 89 |
class="wc3-input"
|
| 90 |
v-model="question"
|
| 91 |
-
placeholder="
|
| 92 |
/>
|
| 93 |
</div>
|
| 94 |
|
| 95 |
<div v-if="selectedFile" style="margin-bottom: 1.5rem; height: 38px; min-width: 160px; display: inline-block;">
|
| 96 |
<Wc3Button size="s" @click="handleAnalyze" :disabled="isLoading">
|
| 97 |
-
{{ isLoading ? '
|
| 98 |
</Wc3Button>
|
| 99 |
</div>
|
| 100 |
|
| 101 |
<div v-if="isLoading" class="loading">
|
| 102 |
-
<span>
|
| 103 |
<span class="dot-pulse"><span></span><span></span><span></span></span>
|
| 104 |
</div>
|
| 105 |
|
|
@@ -110,6 +113,7 @@ async function handleAnalyze() {
|
|
| 110 |
<CitationPanel
|
| 111 |
v-if="showCitations && citations.length"
|
| 112 |
:citations="citations"
|
|
|
|
| 113 |
style="margin-top: 0.75rem;"
|
| 114 |
/>
|
| 115 |
</div>
|
|
|
|
| 2 |
import { ref } from 'vue'
|
| 3 |
import { marked } from 'marked'
|
| 4 |
import type { Citation } from '../types'
|
| 5 |
+
import type { Lang } from '../i18n'
|
| 6 |
+
import { t } from '../i18n'
|
| 7 |
import { analyzeImage } from '../api'
|
| 8 |
import Wc3Button from '../wc3/base/Button.vue'
|
| 9 |
import CitationPanel from './CitationPanel.vue'
|
|
|
|
| 11 |
const props = defineProps<{
|
| 12 |
model: string
|
| 13 |
showCitations: boolean
|
| 14 |
+
lang: Lang
|
| 15 |
}>()
|
| 16 |
|
| 17 |
const selectedFile = ref<File | null>(null)
|
|
|
|
| 61 |
analysis.value = res.analysis
|
| 62 |
citations.value = res.citations
|
| 63 |
} catch (e: any) {
|
| 64 |
+
error.value = e.message || t('image.error', props.lang)
|
| 65 |
} finally {
|
| 66 |
isLoading.value = false
|
| 67 |
}
|
|
|
|
| 79 |
<input ref="fileInput" type="file" accept="image/*" @change="handleFileSelect" />
|
| 80 |
<div v-if="!imagePreview">
|
| 81 |
<div class="icon">📄</div>
|
| 82 |
+
<p>{{ t('image.dropHint', lang) }}</p>
|
| 83 |
<p style="font-size: 0.8rem; color: var(--wc3-text-muted);">
|
| 84 |
+
{{ t('image.uploadHint', lang) }}
|
| 85 |
</p>
|
| 86 |
</div>
|
| 87 |
<img v-else :src="imagePreview" class="image-preview" alt="Preview" />
|
|
|
|
| 91 |
<input
|
| 92 |
class="wc3-input"
|
| 93 |
v-model="question"
|
| 94 |
+
:placeholder="t('image.questionPlaceholder', lang)"
|
| 95 |
/>
|
| 96 |
</div>
|
| 97 |
|
| 98 |
<div v-if="selectedFile" style="margin-bottom: 1.5rem; height: 38px; min-width: 160px; display: inline-block;">
|
| 99 |
<Wc3Button size="s" @click="handleAnalyze" :disabled="isLoading">
|
| 100 |
+
{{ isLoading ? t('image.analyzing', lang) : t('image.analyze', lang) }}
|
| 101 |
</Wc3Button>
|
| 102 |
</div>
|
| 103 |
|
| 104 |
<div v-if="isLoading" class="loading">
|
| 105 |
+
<span>{{ t('image.analyzingStatus', lang) }}</span>
|
| 106 |
<span class="dot-pulse"><span></span><span></span><span></span></span>
|
| 107 |
</div>
|
| 108 |
|
|
|
|
| 113 |
<CitationPanel
|
| 114 |
v-if="showCitations && citations.length"
|
| 115 |
:citations="citations"
|
| 116 |
+
:lang="lang"
|
| 117 |
style="margin-top: 0.75rem;"
|
| 118 |
/>
|
| 119 |
</div>
|
frontend/src/components/LoginPage.vue
CHANGED
|
@@ -1,12 +1,16 @@
|
|
| 1 |
<script setup lang="ts">
|
| 2 |
import { onMounted, ref } from 'vue'
|
|
|
|
|
|
|
| 3 |
|
| 4 |
const props = defineProps<{
|
| 5 |
googleClientId: string
|
|
|
|
| 6 |
}>()
|
| 7 |
|
| 8 |
const emit = defineEmits<{
|
| 9 |
'google-login': [credential: string]
|
|
|
|
| 10 |
}>()
|
| 11 |
|
| 12 |
const error = ref('')
|
|
@@ -24,7 +28,6 @@ try {
|
|
| 24 |
|
| 25 |
// Build the direct .hf.space URL from the current page
|
| 26 |
if (isInIframe.value) {
|
| 27 |
-
// Inside HF iframe at huggingface.co/spaces/User/Space → direct URL is user-space.hf.space
|
| 28 |
const match = window.location.href.match(/spaces\/([^/]+)\/([^/?#]+)/)
|
| 29 |
if (match) {
|
| 30 |
directUrl.value = `https://${match[1].toLowerCase()}-${match[2].toLowerCase().replace(/_/g, '-')}.hf.space`
|
|
@@ -33,11 +36,14 @@ if (isInIframe.value) {
|
|
| 33 |
}
|
| 34 |
}
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
onMounted(() => {
|
| 37 |
-
// Check for credential in URL hash (returned from redirect flow)
|
| 38 |
checkRedirectResult()
|
| 39 |
|
| 40 |
-
// Load Google Identity Services script dynamically
|
| 41 |
if (document.getElementById('gsi-script')) {
|
| 42 |
initGsi()
|
| 43 |
return
|
|
@@ -50,14 +56,13 @@ onMounted(() => {
|
|
| 50 |
script.defer = true
|
| 51 |
script.onload = initGsi
|
| 52 |
script.onerror = () => {
|
| 53 |
-
error.value = '
|
| 54 |
}
|
| 55 |
document.head.appendChild(script)
|
| 56 |
})
|
| 57 |
|
| 58 |
function checkRedirectResult() {
|
| 59 |
// After Google redirect, the credential comes back as a POST to the current page
|
| 60 |
-
// We handle this via the callback in initialize()
|
| 61 |
}
|
| 62 |
|
| 63 |
function initGsi() {
|
|
@@ -71,7 +76,6 @@ function initGsi() {
|
|
| 71 |
client_id: props.googleClientId,
|
| 72 |
callback: handleCredentialResponse,
|
| 73 |
auto_select: false,
|
| 74 |
-
// Use redirect mode inside iframes (popups are blocked)
|
| 75 |
ux_mode: isInIframe.value ? 'redirect' : 'popup',
|
| 76 |
login_uri: isInIframe.value ? (directUrl.value || window.location.origin) + '/' : undefined,
|
| 77 |
})
|
|
@@ -93,7 +97,7 @@ function handleCredentialResponse(response: { credential: string }) {
|
|
| 93 |
loading.value = true
|
| 94 |
emit('google-login', response.credential)
|
| 95 |
} else {
|
| 96 |
-
error.value = '
|
| 97 |
}
|
| 98 |
}
|
| 99 |
</script>
|
|
@@ -101,33 +105,46 @@ function handleCredentialResponse(response: { credential: string }) {
|
|
| 101 |
<template>
|
| 102 |
<div class="login-page">
|
| 103 |
<div class="login-card">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
<!-- WC3-style frame -->
|
| 105 |
<div class="login-frame">
|
| 106 |
<div class="login-icon">⚔️</div>
|
| 107 |
-
<h1>
|
| 108 |
-
<p class="subtitle">
|
| 109 |
|
| 110 |
<div class="gold-divider"></div>
|
| 111 |
|
| 112 |
-
<p class="login-prompt">
|
| 113 |
|
| 114 |
<!-- Google Sign-In button renders here -->
|
| 115 |
<div id="g-signin-btn" class="g-btn-container"></div>
|
| 116 |
|
| 117 |
-
<p v-if="loading" class="login-loading">
|
| 118 |
<p v-if="error" class="login-error">{{ error }}</p>
|
| 119 |
|
| 120 |
-
<!-- Iframe hint
|
| 121 |
<p v-if="isInIframe" class="iframe-hint">
|
| 122 |
-
|
| 123 |
<a :href="directUrl" target="_top" class="direct-link">{{ directUrl }}</a>
|
| 124 |
</p>
|
| 125 |
|
| 126 |
<div class="gold-divider"></div>
|
| 127 |
|
| 128 |
<p class="login-footer">
|
| 129 |
-
|
| 130 |
-
<span class="login-footer-dim">
|
| 131 |
</p>
|
| 132 |
</div>
|
| 133 |
</div>
|
|
@@ -148,6 +165,46 @@ function handleCredentialResponse(response: { credential: string }) {
|
|
| 148 |
width: 100%;
|
| 149 |
max-width: 480px;
|
| 150 |
padding: 2rem;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
}
|
| 152 |
|
| 153 |
.login-frame {
|
|
|
|
| 1 |
<script setup lang="ts">
|
| 2 |
import { onMounted, ref } from 'vue'
|
| 3 |
+
import { t, getStoredLang, storeLang } from '../i18n'
|
| 4 |
+
import type { Lang } from '../i18n'
|
| 5 |
|
| 6 |
const props = defineProps<{
|
| 7 |
googleClientId: string
|
| 8 |
+
lang: Lang
|
| 9 |
}>()
|
| 10 |
|
| 11 |
const emit = defineEmits<{
|
| 12 |
'google-login': [credential: string]
|
| 13 |
+
'update:lang': [lang: Lang]
|
| 14 |
}>()
|
| 15 |
|
| 16 |
const error = ref('')
|
|
|
|
| 28 |
|
| 29 |
// Build the direct .hf.space URL from the current page
|
| 30 |
if (isInIframe.value) {
|
|
|
|
| 31 |
const match = window.location.href.match(/spaces\/([^/]+)\/([^/?#]+)/)
|
| 32 |
if (match) {
|
| 33 |
directUrl.value = `https://${match[1].toLowerCase()}-${match[2].toLowerCase().replace(/_/g, '-')}.hf.space`
|
|
|
|
| 36 |
}
|
| 37 |
}
|
| 38 |
|
| 39 |
+
function switchLang(lang: Lang) {
|
| 40 |
+
storeLang(lang)
|
| 41 |
+
emit('update:lang', lang)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
onMounted(() => {
|
|
|
|
| 45 |
checkRedirectResult()
|
| 46 |
|
|
|
|
| 47 |
if (document.getElementById('gsi-script')) {
|
| 48 |
initGsi()
|
| 49 |
return
|
|
|
|
| 56 |
script.defer = true
|
| 57 |
script.onload = initGsi
|
| 58 |
script.onerror = () => {
|
| 59 |
+
error.value = t('login.error.gsi', props.lang)
|
| 60 |
}
|
| 61 |
document.head.appendChild(script)
|
| 62 |
})
|
| 63 |
|
| 64 |
function checkRedirectResult() {
|
| 65 |
// After Google redirect, the credential comes back as a POST to the current page
|
|
|
|
| 66 |
}
|
| 67 |
|
| 68 |
function initGsi() {
|
|
|
|
| 76 |
client_id: props.googleClientId,
|
| 77 |
callback: handleCredentialResponse,
|
| 78 |
auto_select: false,
|
|
|
|
| 79 |
ux_mode: isInIframe.value ? 'redirect' : 'popup',
|
| 80 |
login_uri: isInIframe.value ? (directUrl.value || window.location.origin) + '/' : undefined,
|
| 81 |
})
|
|
|
|
| 97 |
loading.value = true
|
| 98 |
emit('google-login', response.credential)
|
| 99 |
} else {
|
| 100 |
+
error.value = t('login.error.failed', props.lang)
|
| 101 |
}
|
| 102 |
}
|
| 103 |
</script>
|
|
|
|
| 105 |
<template>
|
| 106 |
<div class="login-page">
|
| 107 |
<div class="login-card">
|
| 108 |
+
<!-- Language toggle (top-right of card) -->
|
| 109 |
+
<div class="lang-toggle">
|
| 110 |
+
<button
|
| 111 |
+
:class="['lang-btn', { active: lang === 'en' }]"
|
| 112 |
+
@click="switchLang('en')"
|
| 113 |
+
>EN</button>
|
| 114 |
+
<span class="lang-sep">|</span>
|
| 115 |
+
<button
|
| 116 |
+
:class="['lang-btn', { active: lang === 'hr' }]"
|
| 117 |
+
@click="switchLang('hr')"
|
| 118 |
+
>HR</button>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
<!-- WC3-style frame -->
|
| 122 |
<div class="login-frame">
|
| 123 |
<div class="login-icon">⚔️</div>
|
| 124 |
+
<h1>{{ t('login.title', lang) }}</h1>
|
| 125 |
+
<p class="subtitle">{{ t('login.subtitle', lang) }}</p>
|
| 126 |
|
| 127 |
<div class="gold-divider"></div>
|
| 128 |
|
| 129 |
+
<p class="login-prompt">{{ t('login.prompt', lang) }}</p>
|
| 130 |
|
| 131 |
<!-- Google Sign-In button renders here -->
|
| 132 |
<div id="g-signin-btn" class="g-btn-container"></div>
|
| 133 |
|
| 134 |
+
<p v-if="loading" class="login-loading">{{ t('login.loading', lang) }}</p>
|
| 135 |
<p v-if="error" class="login-error">{{ error }}</p>
|
| 136 |
|
| 137 |
+
<!-- Iframe hint -->
|
| 138 |
<p v-if="isInIframe" class="iframe-hint">
|
| 139 |
+
{{ t('login.iframe.hint', lang) }}<br>
|
| 140 |
<a :href="directUrl" target="_top" class="direct-link">{{ directUrl }}</a>
|
| 141 |
</p>
|
| 142 |
|
| 143 |
<div class="gold-divider"></div>
|
| 144 |
|
| 145 |
<p class="login-footer">
|
| 146 |
+
{{ t('login.footer.powered', lang) }}<br>
|
| 147 |
+
<span class="login-footer-dim">{{ t('login.footer.edition', lang) }}</span>
|
| 148 |
</p>
|
| 149 |
</div>
|
| 150 |
</div>
|
|
|
|
| 165 |
width: 100%;
|
| 166 |
max-width: 480px;
|
| 167 |
padding: 2rem;
|
| 168 |
+
position: relative;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
/* ---- Language Toggle ---- */
|
| 172 |
+
.lang-toggle {
|
| 173 |
+
display: flex;
|
| 174 |
+
align-items: center;
|
| 175 |
+
justify-content: center;
|
| 176 |
+
gap: 0.25rem;
|
| 177 |
+
margin-bottom: 0.75rem;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.lang-btn {
|
| 181 |
+
background: none;
|
| 182 |
+
border: 1px solid transparent;
|
| 183 |
+
color: var(--wc3-text-dim, #7a6e5a);
|
| 184 |
+
font-family: var(--font-heading, 'Cinzel', serif);
|
| 185 |
+
font-size: 0.8rem;
|
| 186 |
+
letter-spacing: 1.5px;
|
| 187 |
+
padding: 0.3rem 0.75rem;
|
| 188 |
+
cursor: pointer;
|
| 189 |
+
border-radius: 3px;
|
| 190 |
+
transition: all 0.15s;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.lang-btn:hover {
|
| 194 |
+
color: var(--wc3-gold, #c8aa6e);
|
| 195 |
+
border-color: var(--wc3-gold-dim, #8b7b4f);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.lang-btn.active {
|
| 199 |
+
color: var(--wc3-gold-bright, #f0d060);
|
| 200 |
+
border-color: var(--wc3-gold, #c8aa6e);
|
| 201 |
+
background: rgba(200, 170, 110, 0.08);
|
| 202 |
+
text-shadow: 0 0 8px rgba(200, 170, 110, 0.3);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.lang-sep {
|
| 206 |
+
color: var(--wc3-border, #5a4a2a);
|
| 207 |
+
font-size: 0.8rem;
|
| 208 |
}
|
| 209 |
|
| 210 |
.login-frame {
|
frontend/src/components/SidePanel.vue
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
<script setup lang="ts">
|
| 2 |
import type { ModelInfo, StatsResponse, AuthUser } from '../types'
|
|
|
|
|
|
|
| 3 |
import Wc3Button from '../wc3/base/Button.vue'
|
| 4 |
import { RACE_KEY } from '../wc3/consts'
|
| 5 |
|
|
@@ -7,6 +9,7 @@ defineProps<{
|
|
| 7 |
models: Record<string, ModelInfo>
|
| 8 |
stats: StatsResponse
|
| 9 |
user: AuthUser | null
|
|
|
|
| 10 |
}>()
|
| 11 |
|
| 12 |
const model = defineModel<string>('model', { required: true })
|
|
@@ -16,6 +19,7 @@ const race = defineModel<RACE_KEY>('race', { required: true })
|
|
| 16 |
const emit = defineEmits<{
|
| 17 |
'clear-chat': []
|
| 18 |
'logout': []
|
|
|
|
| 19 |
}>()
|
| 20 |
|
| 21 |
const races = [
|
|
@@ -24,6 +28,11 @@ const races = [
|
|
| 24 |
{ key: RACE_KEY.NIGHT_ELF, label: 'Night Elf' },
|
| 25 |
{ key: RACE_KEY.UNDEAD, label: 'Undead' },
|
| 26 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
</script>
|
| 28 |
|
| 29 |
<template>
|
|
@@ -33,14 +42,29 @@ const races = [
|
|
| 33 |
<img v-if="user.picture" :src="user.picture" class="user-avatar" referrerpolicy="no-referrer" alt="" />
|
| 34 |
<div class="user-info">
|
| 35 |
<span class="user-name">{{ user.name }}</span>
|
| 36 |
-
<button class="logout-btn" @click="emit('logout')">
|
| 37 |
</div>
|
| 38 |
</div>
|
| 39 |
|
| 40 |
<div v-if="user" class="gold-divider"></div>
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
<!-- Model -->
|
| 43 |
-
<h2>
|
| 44 |
<select class="wc3-select" :value="model" @change="model = ($event.target as HTMLSelectElement).value">
|
| 45 |
<option v-for="(info, key) in models" :key="key" :value="key">
|
| 46 |
{{ info.name }}
|
|
@@ -53,7 +77,7 @@ const races = [
|
|
| 53 |
<div class="gold-divider"></div>
|
| 54 |
|
| 55 |
<!-- Theme -->
|
| 56 |
-
<h2>
|
| 57 |
<select class="wc3-select" :value="race" @change="race = ($event.target as HTMLSelectElement).value as RACE_KEY">
|
| 58 |
<option v-for="r in races" :key="r.key" :value="r.key">
|
| 59 |
{{ r.label }}
|
|
@@ -63,24 +87,24 @@ const races = [
|
|
| 63 |
<div class="gold-divider"></div>
|
| 64 |
|
| 65 |
<!-- Options -->
|
| 66 |
-
<h2>
|
| 67 |
<label class="wc3-checkbox">
|
| 68 |
<input type="checkbox" :checked="showCitations" @change="showCitations = ($event.target as HTMLInputElement).checked" />
|
| 69 |
-
|
| 70 |
</label>
|
| 71 |
|
| 72 |
<div style="margin-top: 0.75rem; height: 32px; min-width: 100%;">
|
| 73 |
<Wc3Button size="s" @click="emit('clear-chat')">
|
| 74 |
-
|
| 75 |
</Wc3Button>
|
| 76 |
</div>
|
| 77 |
|
| 78 |
<div class="gold-divider"></div>
|
| 79 |
|
| 80 |
<!-- Stats -->
|
| 81 |
-
<h2>
|
| 82 |
<div class="resource-bar">
|
| 83 |
-
{{ stats.documents.toLocaleString() }}
|
| 84 |
</div>
|
| 85 |
|
| 86 |
<!-- Footer -->
|
|
@@ -91,3 +115,34 @@ const races = [
|
|
| 91 |
</div>
|
| 92 |
</div>
|
| 93 |
</template>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
<script setup lang="ts">
|
| 2 |
import type { ModelInfo, StatsResponse, AuthUser } from '../types'
|
| 3 |
+
import type { Lang } from '../i18n'
|
| 4 |
+
import { t, storeLang } from '../i18n'
|
| 5 |
import Wc3Button from '../wc3/base/Button.vue'
|
| 6 |
import { RACE_KEY } from '../wc3/consts'
|
| 7 |
|
|
|
|
| 9 |
models: Record<string, ModelInfo>
|
| 10 |
stats: StatsResponse
|
| 11 |
user: AuthUser | null
|
| 12 |
+
lang: Lang
|
| 13 |
}>()
|
| 14 |
|
| 15 |
const model = defineModel<string>('model', { required: true })
|
|
|
|
| 19 |
const emit = defineEmits<{
|
| 20 |
'clear-chat': []
|
| 21 |
'logout': []
|
| 22 |
+
'update:lang': [lang: Lang]
|
| 23 |
}>()
|
| 24 |
|
| 25 |
const races = [
|
|
|
|
| 28 |
{ key: RACE_KEY.NIGHT_ELF, label: 'Night Elf' },
|
| 29 |
{ key: RACE_KEY.UNDEAD, label: 'Undead' },
|
| 30 |
]
|
| 31 |
+
|
| 32 |
+
function switchLang(newLang: Lang) {
|
| 33 |
+
storeLang(newLang)
|
| 34 |
+
emit('update:lang', newLang)
|
| 35 |
+
}
|
| 36 |
</script>
|
| 37 |
|
| 38 |
<template>
|
|
|
|
| 42 |
<img v-if="user.picture" :src="user.picture" class="user-avatar" referrerpolicy="no-referrer" alt="" />
|
| 43 |
<div class="user-info">
|
| 44 |
<span class="user-name">{{ user.name }}</span>
|
| 45 |
+
<button class="logout-btn" @click="emit('logout')">{{ t('sidebar.logout', lang) }}</button>
|
| 46 |
</div>
|
| 47 |
</div>
|
| 48 |
|
| 49 |
<div v-if="user" class="gold-divider"></div>
|
| 50 |
|
| 51 |
+
<!-- Language -->
|
| 52 |
+
<h2>{{ t('sidebar.language', lang) }}</h2>
|
| 53 |
+
<div class="lang-switch">
|
| 54 |
+
<button
|
| 55 |
+
:class="['lang-switch-btn', { active: lang === 'en' }]"
|
| 56 |
+
@click="switchLang('en')"
|
| 57 |
+
>🇬🇧 English</button>
|
| 58 |
+
<button
|
| 59 |
+
:class="['lang-switch-btn', { active: lang === 'hr' }]"
|
| 60 |
+
@click="switchLang('hr')"
|
| 61 |
+
>🇭🇷 Hrvatski</button>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div class="gold-divider"></div>
|
| 65 |
+
|
| 66 |
<!-- Model -->
|
| 67 |
+
<h2>{{ t('sidebar.selectModel', lang) }}</h2>
|
| 68 |
<select class="wc3-select" :value="model" @change="model = ($event.target as HTMLSelectElement).value">
|
| 69 |
<option v-for="(info, key) in models" :key="key" :value="key">
|
| 70 |
{{ info.name }}
|
|
|
|
| 77 |
<div class="gold-divider"></div>
|
| 78 |
|
| 79 |
<!-- Theme -->
|
| 80 |
+
<h2>{{ t('sidebar.theme', lang) }}</h2>
|
| 81 |
<select class="wc3-select" :value="race" @change="race = ($event.target as HTMLSelectElement).value as RACE_KEY">
|
| 82 |
<option v-for="r in races" :key="r.key" :value="r.key">
|
| 83 |
{{ r.label }}
|
|
|
|
| 87 |
<div class="gold-divider"></div>
|
| 88 |
|
| 89 |
<!-- Options -->
|
| 90 |
+
<h2>{{ t('sidebar.options', lang) }}</h2>
|
| 91 |
<label class="wc3-checkbox">
|
| 92 |
<input type="checkbox" :checked="showCitations" @change="showCitations = ($event.target as HTMLInputElement).checked" />
|
| 93 |
+
{{ t('sidebar.showSources', lang) }}
|
| 94 |
</label>
|
| 95 |
|
| 96 |
<div style="margin-top: 0.75rem; height: 32px; min-width: 100%;">
|
| 97 |
<Wc3Button size="s" @click="emit('clear-chat')">
|
| 98 |
+
{{ t('sidebar.newChat', lang) }}
|
| 99 |
</Wc3Button>
|
| 100 |
</div>
|
| 101 |
|
| 102 |
<div class="gold-divider"></div>
|
| 103 |
|
| 104 |
<!-- Stats -->
|
| 105 |
+
<h2>{{ t('sidebar.knowledgeBase', lang) }}</h2>
|
| 106 |
<div class="resource-bar">
|
| 107 |
+
{{ stats.documents.toLocaleString() }} {{ t('sidebar.documents', lang) }}
|
| 108 |
</div>
|
| 109 |
|
| 110 |
<!-- Footer -->
|
|
|
|
| 115 |
</div>
|
| 116 |
</div>
|
| 117 |
</template>
|
| 118 |
+
|
| 119 |
+
<style scoped>
|
| 120 |
+
.lang-switch {
|
| 121 |
+
display: flex;
|
| 122 |
+
gap: 0.4rem;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.lang-switch-btn {
|
| 126 |
+
flex: 1;
|
| 127 |
+
background: linear-gradient(180deg, #1a1a32, #13132a);
|
| 128 |
+
border: 1px solid var(--wc3-border-dim, #3a2e1e);
|
| 129 |
+
border-radius: 3px;
|
| 130 |
+
color: var(--wc3-text-dim, #7a6e5a);
|
| 131 |
+
font-family: var(--font-body, 'Crimson Text', serif);
|
| 132 |
+
font-size: 0.85rem;
|
| 133 |
+
padding: 0.4rem 0.5rem;
|
| 134 |
+
cursor: pointer;
|
| 135 |
+
transition: all 0.15s;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.lang-switch-btn:hover {
|
| 139 |
+
border-color: var(--wc3-gold-dim, #8b7b4f);
|
| 140 |
+
color: var(--wc3-text, #d4c4a0);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.lang-switch-btn.active {
|
| 144 |
+
border-color: var(--wc3-gold, #c8aa6e);
|
| 145 |
+
color: var(--wc3-gold, #c8aa6e);
|
| 146 |
+
background: rgba(200, 170, 110, 0.08);
|
| 147 |
+
}
|
| 148 |
+
</style>
|
frontend/src/i18n.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Simple i18n system for Learn Pathophysiology
|
| 3 |
+
* Supports: English (en) and Croatian (hr)
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
export type Lang = 'en' | 'hr'
|
| 7 |
+
|
| 8 |
+
export const LANG_STORAGE_KEY = 'lp_lang'
|
| 9 |
+
|
| 10 |
+
export function getStoredLang(): Lang {
|
| 11 |
+
const stored = localStorage.getItem(LANG_STORAGE_KEY)
|
| 12 |
+
if (stored === 'en' || stored === 'hr') return stored
|
| 13 |
+
return 'hr' // default to Croatian
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function storeLang(lang: Lang) {
|
| 17 |
+
localStorage.setItem(LANG_STORAGE_KEY, lang)
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const translations = {
|
| 21 |
+
// ---- Login Page ----
|
| 22 |
+
'login.title': {
|
| 23 |
+
en: 'Learn Pathophysiology',
|
| 24 |
+
hr: 'Learn Pathophysiology',
|
| 25 |
+
},
|
| 26 |
+
'login.subtitle': {
|
| 27 |
+
en: 'AI assistant for learning pathophysiology',
|
| 28 |
+
hr: 'AI asistent za učenje patofiziologije',
|
| 29 |
+
},
|
| 30 |
+
'login.prompt': {
|
| 31 |
+
en: 'Sign in to continue',
|
| 32 |
+
hr: 'Prijavi se za pristup',
|
| 33 |
+
},
|
| 34 |
+
'login.loading': {
|
| 35 |
+
en: 'Signing in...',
|
| 36 |
+
hr: 'Prijava u tijeku...',
|
| 37 |
+
},
|
| 38 |
+
'login.error.gsi': {
|
| 39 |
+
en: 'Failed to load Google Sign-In. Check your internet connection.',
|
| 40 |
+
hr: 'Greška pri učitavanju Google prijave. Provjeri internet vezu.',
|
| 41 |
+
},
|
| 42 |
+
'login.error.failed': {
|
| 43 |
+
en: 'Google sign-in failed. Please try again.',
|
| 44 |
+
hr: 'Google prijava nije uspjela. Pokušaj ponovo.',
|
| 45 |
+
},
|
| 46 |
+
'login.iframe.hint': {
|
| 47 |
+
en: 'If login doesn\'t work, open the app directly:',
|
| 48 |
+
hr: 'Ako prijava ne radi, otvori aplikaciju direktno:',
|
| 49 |
+
},
|
| 50 |
+
'login.footer.powered': {
|
| 51 |
+
en: 'Powered by Gemini AI',
|
| 52 |
+
hr: 'Powered by Gemini AI',
|
| 53 |
+
},
|
| 54 |
+
'login.footer.edition': {
|
| 55 |
+
en: 'WC3 Edition',
|
| 56 |
+
hr: 'WC3 Edition',
|
| 57 |
+
},
|
| 58 |
+
|
| 59 |
+
// ---- App Header ----
|
| 60 |
+
'app.title': {
|
| 61 |
+
en: 'Learn Pathophysiology',
|
| 62 |
+
hr: 'Learn Pathophysiology',
|
| 63 |
+
},
|
| 64 |
+
'app.subtitle': {
|
| 65 |
+
en: 'AI assistant for learning pathophysiology',
|
| 66 |
+
hr: 'AI asistent za ucenje patofiziologije',
|
| 67 |
+
},
|
| 68 |
+
'app.loading': {
|
| 69 |
+
en: 'Loading...',
|
| 70 |
+
hr: 'Učitavanje...',
|
| 71 |
+
},
|
| 72 |
+
|
| 73 |
+
// ---- Tabs ----
|
| 74 |
+
'tab.chat': {
|
| 75 |
+
en: 'Chat',
|
| 76 |
+
hr: 'Chat',
|
| 77 |
+
},
|
| 78 |
+
'tab.image': {
|
| 79 |
+
en: 'Image Analysis',
|
| 80 |
+
hr: 'Analiza slike',
|
| 81 |
+
},
|
| 82 |
+
|
| 83 |
+
// ---- Sidebar ----
|
| 84 |
+
'sidebar.selectModel': {
|
| 85 |
+
en: 'Select model',
|
| 86 |
+
hr: 'Odaberi model',
|
| 87 |
+
},
|
| 88 |
+
'sidebar.theme': {
|
| 89 |
+
en: 'Theme',
|
| 90 |
+
hr: 'Tema',
|
| 91 |
+
},
|
| 92 |
+
'sidebar.options': {
|
| 93 |
+
en: 'Options',
|
| 94 |
+
hr: 'Opcije',
|
| 95 |
+
},
|
| 96 |
+
'sidebar.showSources': {
|
| 97 |
+
en: 'Show sources',
|
| 98 |
+
hr: 'Prikazi izvore',
|
| 99 |
+
},
|
| 100 |
+
'sidebar.newChat': {
|
| 101 |
+
en: 'New conversation',
|
| 102 |
+
hr: 'Novi razgovor',
|
| 103 |
+
},
|
| 104 |
+
'sidebar.knowledgeBase': {
|
| 105 |
+
en: 'Knowledge Base',
|
| 106 |
+
hr: 'Baza znanja',
|
| 107 |
+
},
|
| 108 |
+
'sidebar.documents': {
|
| 109 |
+
en: 'documents',
|
| 110 |
+
hr: 'dokumenata',
|
| 111 |
+
},
|
| 112 |
+
'sidebar.logout': {
|
| 113 |
+
en: 'Sign out',
|
| 114 |
+
hr: 'Odjavi se',
|
| 115 |
+
},
|
| 116 |
+
'sidebar.language': {
|
| 117 |
+
en: 'Language',
|
| 118 |
+
hr: 'Jezik',
|
| 119 |
+
},
|
| 120 |
+
|
| 121 |
+
// ---- Chat ----
|
| 122 |
+
'chat.welcome': {
|
| 123 |
+
en: 'Welcome!',
|
| 124 |
+
hr: 'Dobrodosli!',
|
| 125 |
+
},
|
| 126 |
+
'chat.welcomeHint': {
|
| 127 |
+
en: 'Ask a question about pathophysiology.',
|
| 128 |
+
hr: 'Postavi pitanje o patofiziologiji.',
|
| 129 |
+
},
|
| 130 |
+
'chat.placeholder': {
|
| 131 |
+
en: 'Ask a question about pathophysiology...',
|
| 132 |
+
hr: 'Postavi pitanje o patofiziologiji...',
|
| 133 |
+
},
|
| 134 |
+
'chat.send': {
|
| 135 |
+
en: 'Send',
|
| 136 |
+
hr: 'Posalji',
|
| 137 |
+
},
|
| 138 |
+
'chat.generating': {
|
| 139 |
+
en: 'Generating response',
|
| 140 |
+
hr: 'Generiranje odgovora',
|
| 141 |
+
},
|
| 142 |
+
'chat.error': {
|
| 143 |
+
en: 'Error sending message',
|
| 144 |
+
hr: 'Greska pri slanju poruke',
|
| 145 |
+
},
|
| 146 |
+
|
| 147 |
+
// ---- Image ----
|
| 148 |
+
'image.dropHint': {
|
| 149 |
+
en: 'Click or drag an image here',
|
| 150 |
+
hr: 'Klikni ili povuci sliku ovdje',
|
| 151 |
+
},
|
| 152 |
+
'image.uploadHint': {
|
| 153 |
+
en: 'Upload a page for analysis',
|
| 154 |
+
hr: 'Uploadaj stranicu za analizu',
|
| 155 |
+
},
|
| 156 |
+
'image.questionPlaceholder': {
|
| 157 |
+
en: 'Ask a question about the image (optional)...',
|
| 158 |
+
hr: 'Postavi pitanje o slici (opcionalno)...',
|
| 159 |
+
},
|
| 160 |
+
'image.analyze': {
|
| 161 |
+
en: 'Analyze image',
|
| 162 |
+
hr: 'Analiziraj sliku',
|
| 163 |
+
},
|
| 164 |
+
'image.analyzing': {
|
| 165 |
+
en: 'Analyzing...',
|
| 166 |
+
hr: 'Analiziram...',
|
| 167 |
+
},
|
| 168 |
+
'image.analyzingStatus': {
|
| 169 |
+
en: 'Analyzing',
|
| 170 |
+
hr: 'Analiziranje',
|
| 171 |
+
},
|
| 172 |
+
'image.error': {
|
| 173 |
+
en: 'Error analyzing image',
|
| 174 |
+
hr: 'Greska pri analizi slike',
|
| 175 |
+
},
|
| 176 |
+
|
| 177 |
+
// ---- Citations ----
|
| 178 |
+
'citations.sources': {
|
| 179 |
+
en: 'Sources',
|
| 180 |
+
hr: 'Izvori',
|
| 181 |
+
},
|
| 182 |
+
'citations.page': {
|
| 183 |
+
en: 'p.',
|
| 184 |
+
hr: 'str.',
|
| 185 |
+
},
|
| 186 |
+
} as const
|
| 187 |
+
|
| 188 |
+
export type TranslationKey = keyof typeof translations
|
| 189 |
+
|
| 190 |
+
export function t(key: TranslationKey, lang: Lang): string {
|
| 191 |
+
const entry = translations[key]
|
| 192 |
+
if (!entry) return key
|
| 193 |
+
return entry[lang] || entry['en'] || key
|
| 194 |
+
}
|
frontend/src/types.ts
CHANGED
|
@@ -63,4 +63,6 @@ export interface AuthUser {
|
|
| 63 |
export interface AuthResponse {
|
| 64 |
token: string
|
| 65 |
user: AuthUser
|
| 66 |
-
}
|
|
|
|
|
|
|
|
|
| 63 |
export interface AuthResponse {
|
| 64 |
token: string
|
| 65 |
user: AuthUser
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export type { Lang } from './i18n'
|