Vrda commited on
Commit
81046e2
·
1 Parent(s): 559bb7d

Feature: EN/HR language toggle on login + full app i18n

Browse files
backend/api.py CHANGED
@@ -70,7 +70,8 @@ AVAILABLE_MODELS = {
70
  },
71
  }
72
 
73
- SYSTEM_PROMPT = """Ti si "Learn Pathophysiology AI", strucni asistent za ucenje patofiziologije
 
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 = SYSTEM_PROMPT.format(rag_context=rag_context)
 
 
 
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"Student: {content}")
233
  else:
234
- contents.append(f"Asistent: {content}")
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-D8b5zKM1.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-BVDvGT2M.css">
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">Loading...</div>
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>Learn Pathophysiology</h1>
136
- <p class="subtitle">AI asistent za ucenje patofiziologije</p>
137
  </div>
138
  </header>
139
 
@@ -145,7 +159,7 @@ function clearChat() {
145
  :class="['tab-trigger', { active: activeTab === 'chat' }]"
146
  @click="activeTab = 'chat'"
147
  >
148
- Chat
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
- Analiza slike
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 || 'Greska pri slanju poruke'
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
- Dobrodosli!
80
  </p>
81
  <p style="color: var(--wc3-text-dim); font-size: 0.9rem; max-width: 400px; margin: 0.5rem auto;">
82
- Postavi pitanje o patofiziologiji.
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>Generiranje odgovora</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="Postavi pitanje o patofiziologiji..."
114
  :disabled="isLoading"
115
  />
116
  <div class="chat-send-btn">
117
  <Wc3Button size="s" @click="handleSend" :disabled="isLoading || !inputText.trim()">
118
- Posalji
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<{ citations: Citation[] }>()
 
 
 
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
- 📜 Izvori ({{ citations.length }})
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 }} — str. {{ c.page_num }}
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 || 'Greska pri analizi slike'
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>Klikni ili povuci sliku ovdje</p>
80
  <p style="font-size: 0.8rem; color: var(--wc3-text-muted);">
81
- Uploadaj stranicu za analizu
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="Postavi pitanje o slici (opcionalno)..."
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 ? 'Analiziram...' : 'Analiziraj sliku' }}
98
  </Wc3Button>
99
  </div>
100
 
101
  <div v-if="isLoading" class="loading">
102
- <span>Analiziranje</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 = 'Failed to load Google Sign-In. Check your internet connection.'
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 = 'Google sign-in failed. Please try again.'
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>Learn Pathophysiology</h1>
108
- <p class="subtitle">AI asistent za učenje patofiziologije</p>
109
 
110
  <div class="gold-divider"></div>
111
 
112
- <p class="login-prompt">Prijavi se za pristup</p>
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">Prijava u tijeku...</p>
118
  <p v-if="error" class="login-error">{{ error }}</p>
119
 
120
- <!-- Iframe hint: if popups are blocked, offer direct link -->
121
  <p v-if="isInIframe" class="iframe-hint">
122
- Ako prijava ne radi, otvori aplikaciju direktno:<br>
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
- Powered by Gemini AI<br>
130
- <span class="login-footer-dim">WC3 Edition</span>
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')">Odjavi se</button>
37
  </div>
38
  </div>
39
 
40
  <div v-if="user" class="gold-divider"></div>
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  <!-- Model -->
43
- <h2>Odaberi model</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>Tema</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>Opcije</h2>
67
  <label class="wc3-checkbox">
68
  <input type="checkbox" :checked="showCitations" @change="showCitations = ($event.target as HTMLInputElement).checked" />
69
- Prikazi izvore
70
  </label>
71
 
72
  <div style="margin-top: 0.75rem; height: 32px; min-width: 100%;">
73
  <Wc3Button size="s" @click="emit('clear-chat')">
74
- Novi razgovor
75
  </Wc3Button>
76
  </div>
77
 
78
  <div class="gold-divider"></div>
79
 
80
  <!-- Stats -->
81
- <h2>Baza znanja</h2>
82
  <div class="resource-bar">
83
- {{ stats.documents.toLocaleString() }} dokumenata
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'