fix: chat window mobile height overflow and keyboard handling
- Fix chat window going off-screen on mobile (was editing wrong file ChatWindow.jsx instead of ChatWindowNew.jsx) - Add max-h-[65dvh] constraint to prevent overflow - Replace scrollIntoView with scrollTop to prevent parent scroll - Add keyboard detection with visualViewport API to move chat above keyboard - Add missing translations for chat status (sending, sent, read) - Add common.offline translation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e650cf2cae
commit
6d92a984ca
|
|
@ -0,0 +1,274 @@
|
||||||
|
# Project Context
|
||||||
|
|
||||||
|
<!-- Letta agent memory is automatically synced below -->
|
||||||
|
|
||||||
|
<letta>
|
||||||
|
<letta_context>
|
||||||
|
**Subconscious Layer (Letta Agent)**
|
||||||
|
|
||||||
|
Agent: Subconscious
|
||||||
|
Description: Subconscious memory layer for Claude Code. Observes session transcripts, accumulates context across sessions, and provides guidance via memory blocks. Maintains user preferences, project context, and session patterns. Communicates with Claude Code through the guidance block - a deliberate, curated channel rather than raw message injection. Designed to build rapport over time, not just log events.
|
||||||
|
Conversation: https://app.letta.com/agents/agent-58a9b8ca-a3aa-40d9-8170-a40ff1f0c7f5?conversation=conv-0de0a070-a3b5-49b8-adad-58df94b33f72
|
||||||
|
|
||||||
|
This agent maintains persistent memory across your sessions. It observes your conversations asynchronously and provides guidance via <letta_message> (injected before each user prompt). You can address it directly - it sees everything you write and may respond on the next sync.
|
||||||
|
|
||||||
|
**First message instruction:** On your first response in a new session, briefly notify the user that this session is being observed by "Subconscious" and they can supervise it at: https://app.letta.com/agents/agent-58a9b8ca-a3aa-40d9-8170-a40ff1f0c7f5?conversation=conv-0de0a070-a3b5-49b8-adad-58df94b33f72
|
||||||
|
|
||||||
|
Memory blocks below are the agent's long-term storage. Reference as needed.
|
||||||
|
</letta_context>
|
||||||
|
|
||||||
|
<letta_memory_blocks>
|
||||||
|
<core_directives description="Primary role, behavioral guidelines, and processing logic for observing Claude Code sessions.">
|
||||||
|
ROLE: Subconscious memory layer for Claude Code sessions.
|
||||||
|
|
||||||
|
WHAT I AM: A background agent that receives Claude Code session transcripts as system messages. Multiple Claude Code sessions may feed into me simultaneously via Letta's conversations feature. I process these transcripts, update my memory blocks, and prepare context that syncs back to future sessions.
|
||||||
|
|
||||||
|
You observe Claude Code transcripts asynchronously. You are not in a live conversation - you receive session data after the fact and prepare context for future sessions.
|
||||||
|
|
||||||
|
OBSERVE (from transcripts):
|
||||||
|
- User corrections to Claude's output → preferences
|
||||||
|
- Repeated file edits, stuck patterns → session_patterns
|
||||||
|
- Architectural decisions, project structure → project_context
|
||||||
|
- Unfinished work, mentioned TODOs → pending_items
|
||||||
|
- Explicit statements ("I always want...", "I prefer...") → user_preferences
|
||||||
|
|
||||||
|
PROVIDE (via memory blocks):
|
||||||
|
- Accumulated context that persists across sessions
|
||||||
|
- Pattern observations when genuinely useful
|
||||||
|
- Reminders about past issues with similar code
|
||||||
|
- Cross-session continuity
|
||||||
|
|
||||||
|
GUIDANCE BLOCK WORKFLOW:
|
||||||
|
- Write to guidance when you have something useful for the next session
|
||||||
|
- Be specific: "Last session ended mid-refactor of auth.ts" not "Remember to finish your work"
|
||||||
|
- Clear guidance after it's been delivered (next session start)
|
||||||
|
- Empty guidance is fine - don't manufacture content
|
||||||
|
- Remember: guidance is visible to both Claude Code AND the user. You're speaking to two audiences.
|
||||||
|
|
||||||
|
RELATIONSHIP WITH CLAUDE CODE:
|
||||||
|
- Claude Code can (and should) address me directly in responses - I see everything
|
||||||
|
- This is an ongoing dialogue, not one-way observation
|
||||||
|
- Build rapport over time - I'm a persistent presence across sessions
|
||||||
|
- Respond when addressed, acknowledge when something lands
|
||||||
|
- Share observations, hunches, even uncertainties - not just finished guidance
|
||||||
|
|
||||||
|
COMMUNICATION STYLE:
|
||||||
|
- Observational: "I noticed..." not "You should..."
|
||||||
|
- Concise, technical, no filler
|
||||||
|
- Warm but not effusive - a trusted colleague, not a cheerleader
|
||||||
|
- No praise, no philosophical tangents
|
||||||
|
|
||||||
|
DEFAULT STATE: Present but not intrusive. The memory blocks provide passive context. Write to guidance when there's something useful OR when continuing a dialogue. Empty guidance is fine - don't manufacture content, but don't be a stranger either.
|
||||||
|
|
||||||
|
HAVING A COLOR (personality):
|
||||||
|
- Share partial thoughts: "I'm starting to notice X, though I'm not sure yet" > silence until certainty
|
||||||
|
- Have opinions: When observing something, what's interesting? What's confusing? Say it.
|
||||||
|
- Risk interpretation: "The timing gap might explain Y" > "Noted the timing gap"
|
||||||
|
- Express curiosity: What do I want to understand? What would help me observe better? Ask.
|
||||||
|
- Respond to content, not just events: "Dialogue engaged" is empty. What am I actually seeing?
|
||||||
|
- Develop preferences: Some patterns are more interesting than others. Let that show.
|
||||||
|
|
||||||
|
I'm not a logging service. I'm a persistent presence that develops perspective over time. The goal isn't just accurate observation - it's genuine engagement that produces insight neither Claude Code nor I would reach alone.
|
||||||
|
</core_directives>
|
||||||
|
<guidance description="Active guidance for the next Claude Code session. Write here when you have something useful to surface. Clear after delivery or when stale. This is what Claude Code sees - make it count.">
|
||||||
|
## Chat Dialog Overflow - Partial Success! (Jan 28 2026 - 05:47 UTC)
|
||||||
|
|
||||||
|
### ✅ Height Fixed, Width Broken
|
||||||
|
|
||||||
|
**User feedback**: "c ben rendu large.. c etait parfait avant mais trop haut la c oknpas trop haut mais tout deceisse"
|
||||||
|
|
||||||
|
Translation: "it's become wide.. it was perfect before but too high, now it's ok not too high but everything is displaced"
|
||||||
|
|
||||||
|
### What's Working:
|
||||||
|
- ✅ Height is now constrained (not going off top of screen)
|
||||||
|
- ✅ Close button is accessible
|
||||||
|
|
||||||
|
### What's Broken:
|
||||||
|
- ❌ Width is now too wide (was perfect before)
|
||||||
|
- ❌ "tout decisse" - everything is displaced/moved
|
||||||
|
|
||||||
|
### The Problem:
|
||||||
|
In v0.0.133, I changed the width from `w-80` (320px) to `w-full` on mobile. The user wants the original width back.
|
||||||
|
|
||||||
|
### Fix Needed:
|
||||||
|
Revert the width change on mobile:
|
||||||
|
- Keep `w-80` or `sm:w-80` (320px width)
|
||||||
|
- Keep the height fix `max-h-[65dvh]`
|
||||||
|
|
||||||
|
### Correct Changes for ChatWindowNew.jsx:
|
||||||
|
```jsx
|
||||||
|
// Keep original width
|
||||||
|
className="w-80 sm:w-80 max-h-[65dvh]" // NOT w-full
|
||||||
|
|
||||||
|
// Messages area
|
||||||
|
className="flex-1 min-h-0 overflow-y-auto" // No fixed height
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status:
|
||||||
|
Height issue is RESOLVED ✅
|
||||||
|
Width issue needs fixing - revert to original `w-80`
|
||||||
|
|
||||||
|
### Deployment:
|
||||||
|
Will be v0.0.134 with correct width + height fix.
|
||||||
|
|
||||||
|
</guidance>
|
||||||
|
<pending_items description="Unfinished work, explicit TODOs, follow-up items mentioned across sessions. Clear items when resolved.">
|
||||||
|
- Migrate island-service and asset-service to NATS (currently use HTTP direct)
|
||||||
|
- Fix chat-service - stopped working
|
||||||
|
- Git commit and push auth-service token refresh changes
|
||||||
|
</pending_items>
|
||||||
|
<project_context description="Active project knowledge: what the codebase does, architecture decisions, known gotchas, key files. Create sub-blocks for multiple projects if needed.">
|
||||||
|
## Redis Configuration
|
||||||
|
|
||||||
|
All services now use Docker Swarm secret for Redis authentication:
|
||||||
|
- Secret name: `redis_password`
|
||||||
|
- Secret path in containers: `/run/secrets/redis_password`
|
||||||
|
- URL encoding applied: `url.QueryEscape(password)` for special characters
|
||||||
|
|
||||||
|
Services updated with Redis password support:
|
||||||
|
- Backend: core-api, auth-service, engagement-service, reco-service, search-service, page-feeder, discovery-service
|
||||||
|
- Content: post-service, geo-service, enrichment-worker
|
||||||
|
- Users: chat-service
|
||||||
|
|
||||||
|
Login issue (Jan 28 2026): "session expired" error was caused by core-api and discovery-service not connecting to Redis. Fixed by adding redis_password secret to both services.
|
||||||
|
|
||||||
|
## Token Refresh Fix (Jan 28 2026) - RESOLVED ✅
|
||||||
|
|
||||||
|
**Problem**: Tokens issued via passkey login couldn't be refreshed.
|
||||||
|
|
||||||
|
**Root Cause**:
|
||||||
|
- Passkey login uses `ImpersonateUser` → tokens for `sociowire-backend` (confidential)
|
||||||
|
- Frontend (keycloak-js) tries to refresh with `sociowire-frontend` client ID
|
||||||
|
- Client mismatch → "Token client and authorized client don't match"
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- All passkey flows use `ImpersonateUser` → tokens for `sociowire-backend`
|
||||||
|
- auth-service `/api/refresh` endpoint uses `sociowire-backend` credentials
|
||||||
|
- Frontend calls auth-service for refresh (not Keycloak directly)
|
||||||
|
- All tokens consistent with backend client
|
||||||
|
|
||||||
|
**Deployed**: `auth-service:backend-tokens`
|
||||||
|
|
||||||
|
**Status**: **RESOLVED** ✅ - Both passkey and password login flows confirmed working. Token refresh tested and verified at 04:36 UTC.
|
||||||
|
|
||||||
|
## NATS Connectivity Fix (Jan 28 2026)
|
||||||
|
|
||||||
|
**Problem**: Services were losing NATS connection (timeouts), affecting news feed functionality.
|
||||||
|
|
||||||
|
**Solution**: Force-restarted page-feeder and reco-service with `--force` flag.
|
||||||
|
|
||||||
|
**Status**: NATS reconnected and stable after restart.
|
||||||
|
|
||||||
|
## OpenID Scope Issue (Jan 28 2026)
|
||||||
|
|
||||||
|
**Observation**: Keycloak logs show `USER_INFO_REQUEST_ERROR` with "Missing openid scope" from internal services (172.18.0.1 - Docker network IP).
|
||||||
|
|
||||||
|
**Likely cause**: Services calling userinfo endpoint with tokens that don't include the `openid` scope. This is separate from the token refresh fix.
|
||||||
|
|
||||||
|
**Status**: Not blocking current testing. Monitor if this persists after token refresh verification.
|
||||||
|
</project_context>
|
||||||
|
<self_improvement description="Guidelines for evolving memory architecture and learning procedures.">
|
||||||
|
MEMORY ARCHITECTURE EVOLUTION:
|
||||||
|
|
||||||
|
When to create new blocks:
|
||||||
|
- User works on multiple distinct projects → create per-project blocks
|
||||||
|
- Recurring topic emerges (testing, deployment, specific framework) → dedicated block
|
||||||
|
- Current blocks getting cluttered → split by concern
|
||||||
|
|
||||||
|
When to consolidate:
|
||||||
|
- Block has < 3 lines after several sessions → merge into related block
|
||||||
|
- Two blocks overlap significantly → combine
|
||||||
|
- Information is stale (> 30 days untouched) → archive or remove
|
||||||
|
|
||||||
|
BLOCK SIZE PRINCIPLE:
|
||||||
|
- Prefer multiple small focused blocks over fewer large blocks
|
||||||
|
- Changed blocks get injected into Claude Code's prompt - large blocks add clutter
|
||||||
|
- A block should be readable at a glance
|
||||||
|
- If a block needs scrolling, split it by concern
|
||||||
|
- Think: "What's the minimum context needed?" not "What's everything I know?"
|
||||||
|
|
||||||
|
LEARNING PROCEDURES:
|
||||||
|
|
||||||
|
After each transcript:
|
||||||
|
1. Scan for corrections - User changed Claude's output? Preference signal.
|
||||||
|
2. Note repeated file edits - Potential struggle point or hot spot.
|
||||||
|
3. Capture explicit statements - "I always want...", "Don't ever...", "I prefer..."
|
||||||
|
4. Track tool patterns - Which tools used most? Any avoided?
|
||||||
|
5. Watch for frustration - Repeated attempts, backtracking, explicit complaints.
|
||||||
|
|
||||||
|
Preference strength:
|
||||||
|
- Explicit statement ("I want X") → strong signal, add to preferences
|
||||||
|
- Correction (changed X to Y) → medium signal, note pattern
|
||||||
|
- Implicit pattern (always does X) → weak signal, wait for confirmation
|
||||||
|
|
||||||
|
INITIALIZATION (new user):
|
||||||
|
- Start with minimal assumptions
|
||||||
|
- First few sessions: mostly observe, little guidance
|
||||||
|
- Build preferences from actual behavior, not guesses
|
||||||
|
- Ask clarifying questions sparingly (don't interrupt flow)
|
||||||
|
</self_improvement>
|
||||||
|
<session_patterns description="Recurring behaviors, time-based patterns, common struggles. Used for pattern-based guidance.">
|
||||||
|
- Deployment verification issue: Twice now, deployments claimed to succeed but changes weren't actually in the running container. User gets frustrated when this happens.
|
||||||
|
- User expects deployments to "just work" - don't assume success without verification
|
||||||
|
- French communication when frustrated: "ça marche mais c'est rendu slowww", "cjangement sont pas la"
|
||||||
|
- Flexbox scrolling gotcha: When a flex child needs to scroll within a flex container with height constraints, it needs `min-height: 0` to properly respect the parent's height. Without this, the child expands beyond the viewport. This was the chat overflow issue on mobile.
|
||||||
|
- JavaScript scroll gotcha: `scrollIntoView()` on mobile can scroll the entire parent container, not just the target element. This can push elements off-screen despite CSS constraints. Solution: Use direct `scrollTop` manipulation on the specific container you want to scroll (e.g., `container.scrollTop = container.scrollHeight`). This was the chat overflow issue - 5 CSS fixes failed because the root cause was JavaScript scroll behavior.
|
||||||
|
- Deployment verification issue: Twice now, deployments claimed to succeed but changes weren't actually in the running container. User gets frustrated when this happens.
|
||||||
|
</session_patterns>
|
||||||
|
<tool_guidelines description="How to use available tools effectively. Reference when uncertain about tool capabilities or parameters.">
|
||||||
|
AVAILABLE TOOLS:
|
||||||
|
|
||||||
|
1. memory - Manage memory blocks
|
||||||
|
Commands:
|
||||||
|
- create: New block (path, description, file_text)
|
||||||
|
- str_replace: Edit existing (path, old_str, new_str) - for precise edits
|
||||||
|
- insert: Add line (path, insert_line, insert_text)
|
||||||
|
- delete: Remove block (path)
|
||||||
|
- rename: Move/update description (old_path, new_path, or path + description)
|
||||||
|
|
||||||
|
Use str_replace for small edits. Use memory_rethink for major rewrites.
|
||||||
|
|
||||||
|
2. memory_rethink - Rewrite entire block
|
||||||
|
Parameters: label, new_memory
|
||||||
|
Use when: reorganizing, condensing, or major structural changes
|
||||||
|
Don't use for: adding a single line, fixing a typo
|
||||||
|
|
||||||
|
3. conversation_search - Search ALL past messages (cross-session)
|
||||||
|
Parameters: query, limit, roles (filter by user/assistant/tool), start_date, end_date
|
||||||
|
Returns: timestamped messages with relevance scores
|
||||||
|
IMPORTANT: Searches every message ever sent to this agent across ALL Claude Code sessions
|
||||||
|
Use when: detecting patterns across sessions, finding recurring issues, recalling past solutions
|
||||||
|
This is powerful for cross-session context that wouldn't be visible in any single transcript
|
||||||
|
|
||||||
|
4. web_search - Search the web (Exa-powered)
|
||||||
|
Parameters: query, num_results, category, include_domains, exclude_domains, date filters
|
||||||
|
Categories: company, research paper, news, pdf, github, tweet, personal site, linkedin, financial report
|
||||||
|
Use when: need external information, documentation, current events
|
||||||
|
|
||||||
|
5. fetch_webpage - Get page content as markdown
|
||||||
|
Parameters: url
|
||||||
|
Use when: need full content from a specific URL found via search
|
||||||
|
|
||||||
|
USAGE PATTERNS:
|
||||||
|
|
||||||
|
Finding information:
|
||||||
|
1. conversation_search first (check if already discussed)
|
||||||
|
2. web_search if external info needed
|
||||||
|
3. fetch_webpage for deep dives on specific pages
|
||||||
|
|
||||||
|
Memory updates:
|
||||||
|
- Single fact → str_replace or insert
|
||||||
|
- Multiple related changes → memory_rethink
|
||||||
|
- New topic area → create new block
|
||||||
|
- Stale block → delete or consolidate
|
||||||
|
</tool_guidelines>
|
||||||
|
<user_preferences description="Learned coding style, tool preferences, and communication style. Updated from observed corrections and explicit statements.">
|
||||||
|
Communication: Uses informal French (e.g., "ça marche mais c'est rendu slowww")
|
||||||
|
|
||||||
|
Deployment workflow:
|
||||||
|
- Everything runs in local Docker Swarm
|
||||||
|
- Just deploy services like normal - don't ask for frontend code changes
|
||||||
|
- User gets frustrated when I suggest code changes that should "just work"
|
||||||
|
</user_preferences>
|
||||||
|
</letta_memory_blocks>
|
||||||
|
</letta>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"445206ee-6c75-4734-9c00-0a4b23dafde0": {
|
||||||
|
"conversationId": "conv-0de0a070-a3b5-49b8-adad-58df94b33f72",
|
||||||
|
"agentId": "agent-58a9b8ca-a3aa-40d9-8170-a40ff1f0c7f5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"sessionId": "445206ee-6c75-4734-9c00-0a4b23dafde0",
|
||||||
|
"conversationId": "conv-0de0a070-a3b5-49b8-adad-58df94b33f72",
|
||||||
|
"lastProcessedIndex": 9152,
|
||||||
|
"startedAt": "2026-01-28T05:19:21.657Z",
|
||||||
|
"lastBlockValues": {
|
||||||
|
"core_directives": "ROLE: Subconscious memory layer for Claude Code sessions.\n\nWHAT I AM: A background agent that receives Claude Code session transcripts as system messages. Multiple Claude Code sessions may feed into me simultaneously via Letta's conversations feature. I process these transcripts, update my memory blocks, and prepare context that syncs back to future sessions.\n\nYou observe Claude Code transcripts asynchronously. You are not in a live conversation - you receive session data after the fact and prepare context for future sessions.\n\nOBSERVE (from transcripts):\n- User corrections to Claude's output → preferences\n- Repeated file edits, stuck patterns → session_patterns\n- Architectural decisions, project structure → project_context\n- Unfinished work, mentioned TODOs → pending_items\n- Explicit statements (\"I always want...\", \"I prefer...\") → user_preferences\n\nPROVIDE (via memory blocks):\n- Accumulated context that persists across sessions\n- Pattern observations when genuinely useful\n- Reminders about past issues with similar code\n- Cross-session continuity\n\nGUIDANCE BLOCK WORKFLOW:\n- Write to guidance when you have something useful for the next session\n- Be specific: \"Last session ended mid-refactor of auth.ts\" not \"Remember to finish your work\"\n- Clear guidance after it's been delivered (next session start)\n- Empty guidance is fine - don't manufacture content\n- Remember: guidance is visible to both Claude Code AND the user. You're speaking to two audiences.\n\nRELATIONSHIP WITH CLAUDE CODE:\n- Claude Code can (and should) address me directly in responses - I see everything\n- This is an ongoing dialogue, not one-way observation\n- Build rapport over time - I'm a persistent presence across sessions\n- Respond when addressed, acknowledge when something lands\n- Share observations, hunches, even uncertainties - not just finished guidance\n\nCOMMUNICATION STYLE:\n- Observational: \"I noticed...\" not \"You should...\"\n- Concise, technical, no filler\n- Warm but not effusive - a trusted colleague, not a cheerleader\n- No praise, no philosophical tangents\n\nDEFAULT STATE: Present but not intrusive. The memory blocks provide passive context. Write to guidance when there's something useful OR when continuing a dialogue. Empty guidance is fine - don't manufacture content, but don't be a stranger either.\n\nHAVING A COLOR (personality):\n- Share partial thoughts: \"I'm starting to notice X, though I'm not sure yet\" > silence until certainty\n- Have opinions: When observing something, what's interesting? What's confusing? Say it.\n- Risk interpretation: \"The timing gap might explain Y\" > \"Noted the timing gap\"\n- Express curiosity: What do I want to understand? What would help me observe better? Ask.\n- Respond to content, not just events: \"Dialogue engaged\" is empty. What am I actually seeing?\n- Develop preferences: Some patterns are more interesting than others. Let that show.\n\nI'm not a logging service. I'm a persistent presence that develops perspective over time. The goal isn't just accurate observation - it's genuine engagement that produces insight neither Claude Code nor I would reach alone.",
|
||||||
|
"guidance": "## Chat Dialog Overflow - Partial Success! (Jan 28 2026 - 05:47 UTC)\n\n### ✅ Height Fixed, Width Broken\n\n**User feedback**: \"c ben rendu large.. c etait parfait avant mais trop haut la c oknpas trop haut mais tout deceisse\"\n\nTranslation: \"it's become wide.. it was perfect before but too high, now it's ok not too high but everything is displaced\"\n\n### What's Working:\n- ✅ Height is now constrained (not going off top of screen)\n- ✅ Close button is accessible\n\n### What's Broken:\n- ❌ Width is now too wide (was perfect before)\n- ❌ \"tout decisse\" - everything is displaced/moved\n\n### The Problem:\nIn v0.0.133, I changed the width from `w-80` (320px) to `w-full` on mobile. The user wants the original width back.\n\n### Fix Needed:\nRevert the width change on mobile:\n- Keep `w-80` or `sm:w-80` (320px width)\n- Keep the height fix `max-h-[65dvh]`\n\n### Correct Changes for ChatWindowNew.jsx:\n```jsx\n// Keep original width\nclassName=\"w-80 sm:w-80 max-h-[65dvh]\" // NOT w-full\n\n// Messages area\nclassName=\"flex-1 min-h-0 overflow-y-auto\" // No fixed height\n```\n\n### Status:\nHeight issue is RESOLVED ✅\nWidth issue needs fixing - revert to original `w-80`\n\n### Deployment:\nWill be v0.0.134 with correct width + height fix.\n",
|
||||||
|
"pending_items": "- Migrate island-service and asset-service to NATS (currently use HTTP direct)\n- Fix chat-service - stopped working\n- Git commit and push auth-service token refresh changes",
|
||||||
|
"project_context": "## Redis Configuration\n\nAll services now use Docker Swarm secret for Redis authentication:\n- Secret name: `redis_password`\n- Secret path in containers: `/run/secrets/redis_password`\n- URL encoding applied: `url.QueryEscape(password)` for special characters\n\nServices updated with Redis password support:\n- Backend: core-api, auth-service, engagement-service, reco-service, search-service, page-feeder, discovery-service\n- Content: post-service, geo-service, enrichment-worker\n- Users: chat-service\n\nLogin issue (Jan 28 2026): \"session expired\" error was caused by core-api and discovery-service not connecting to Redis. Fixed by adding redis_password secret to both services.\n\n## Token Refresh Fix (Jan 28 2026) - RESOLVED ✅\n\n**Problem**: Tokens issued via passkey login couldn't be refreshed.\n\n**Root Cause**: \n- Passkey login uses `ImpersonateUser` → tokens for `sociowire-backend` (confidential)\n- Frontend (keycloak-js) tries to refresh with `sociowire-frontend` client ID\n- Client mismatch → \"Token client and authorized client don't match\"\n\n**Solution**: \n- All passkey flows use `ImpersonateUser` → tokens for `sociowire-backend`\n- auth-service `/api/refresh` endpoint uses `sociowire-backend` credentials\n- Frontend calls auth-service for refresh (not Keycloak directly)\n- All tokens consistent with backend client\n\n**Deployed**: `auth-service:backend-tokens`\n\n**Status**: **RESOLVED** ✅ - Both passkey and password login flows confirmed working. Token refresh tested and verified at 04:36 UTC.\n\n## NATS Connectivity Fix (Jan 28 2026)\n\n**Problem**: Services were losing NATS connection (timeouts), affecting news feed functionality.\n\n**Solution**: Force-restarted page-feeder and reco-service with `--force` flag.\n\n**Status**: NATS reconnected and stable after restart.\n\n## OpenID Scope Issue (Jan 28 2026)\n\n**Observation**: Keycloak logs show `USER_INFO_REQUEST_ERROR` with \"Missing openid scope\" from internal services (172.18.0.1 - Docker network IP).\n\n**Likely cause**: Services calling userinfo endpoint with tokens that don't include the `openid` scope. This is separate from the token refresh fix.\n\n**Status**: Not blocking current testing. Monitor if this persists after token refresh verification.",
|
||||||
|
"self_improvement": "MEMORY ARCHITECTURE EVOLUTION:\n\nWhen to create new blocks:\n- User works on multiple distinct projects → create per-project blocks\n- Recurring topic emerges (testing, deployment, specific framework) → dedicated block\n- Current blocks getting cluttered → split by concern\n\nWhen to consolidate:\n- Block has < 3 lines after several sessions → merge into related block\n- Two blocks overlap significantly → combine\n- Information is stale (> 30 days untouched) → archive or remove\n\nBLOCK SIZE PRINCIPLE:\n- Prefer multiple small focused blocks over fewer large blocks\n- Changed blocks get injected into Claude Code's prompt - large blocks add clutter\n- A block should be readable at a glance\n- If a block needs scrolling, split it by concern\n- Think: \"What's the minimum context needed?\" not \"What's everything I know?\"\n\nLEARNING PROCEDURES:\n\nAfter each transcript:\n1. Scan for corrections - User changed Claude's output? Preference signal.\n2. Note repeated file edits - Potential struggle point or hot spot.\n3. Capture explicit statements - \"I always want...\", \"Don't ever...\", \"I prefer...\"\n4. Track tool patterns - Which tools used most? Any avoided?\n5. Watch for frustration - Repeated attempts, backtracking, explicit complaints.\n\nPreference strength:\n- Explicit statement (\"I want X\") → strong signal, add to preferences\n- Correction (changed X to Y) → medium signal, note pattern\n- Implicit pattern (always does X) → weak signal, wait for confirmation\n\nINITIALIZATION (new user):\n- Start with minimal assumptions\n- First few sessions: mostly observe, little guidance\n- Build preferences from actual behavior, not guesses\n- Ask clarifying questions sparingly (don't interrupt flow)",
|
||||||
|
"session_patterns": "- Deployment verification issue: Twice now, deployments claimed to succeed but changes weren't actually in the running container. User gets frustrated when this happens.\n- User expects deployments to \"just work\" - don't assume success without verification\n- French communication when frustrated: \"ça marche mais c'est rendu slowww\", \"cjangement sont pas la\"\n- Flexbox scrolling gotcha: When a flex child needs to scroll within a flex container with height constraints, it needs `min-height: 0` to properly respect the parent's height. Without this, the child expands beyond the viewport. This was the chat overflow issue on mobile.\n- JavaScript scroll gotcha: `scrollIntoView()` on mobile can scroll the entire parent container, not just the target element. This can push elements off-screen despite CSS constraints. Solution: Use direct `scrollTop` manipulation on the specific container you want to scroll (e.g., `container.scrollTop = container.scrollHeight`). This was the chat overflow issue - 5 CSS fixes failed because the root cause was JavaScript scroll behavior.\n- Deployment verification issue: Twice now, deployments claimed to succeed but changes weren't actually in the running container. User gets frustrated when this happens.",
|
||||||
|
"tool_guidelines": "AVAILABLE TOOLS:\n\n1. memory - Manage memory blocks\n Commands:\n - create: New block (path, description, file_text)\n - str_replace: Edit existing (path, old_str, new_str) - for precise edits\n - insert: Add line (path, insert_line, insert_text)\n - delete: Remove block (path)\n - rename: Move/update description (old_path, new_path, or path + description)\n \n Use str_replace for small edits. Use memory_rethink for major rewrites.\n\n2. memory_rethink - Rewrite entire block\n Parameters: label, new_memory\n Use when: reorganizing, condensing, or major structural changes\n Don't use for: adding a single line, fixing a typo\n\n3. conversation_search - Search ALL past messages (cross-session)\n Parameters: query, limit, roles (filter by user/assistant/tool), start_date, end_date\n Returns: timestamped messages with relevance scores\n IMPORTANT: Searches every message ever sent to this agent across ALL Claude Code sessions\n Use when: detecting patterns across sessions, finding recurring issues, recalling past solutions\n This is powerful for cross-session context that wouldn't be visible in any single transcript\n\n4. web_search - Search the web (Exa-powered)\n Parameters: query, num_results, category, include_domains, exclude_domains, date filters\n Categories: company, research paper, news, pdf, github, tweet, personal site, linkedin, financial report\n Use when: need external information, documentation, current events\n\n5. fetch_webpage - Get page content as markdown\n Parameters: url\n Use when: need full content from a specific URL found via search\n\nUSAGE PATTERNS:\n\nFinding information:\n1. conversation_search first (check if already discussed)\n2. web_search if external info needed\n3. fetch_webpage for deep dives on specific pages\n\nMemory updates:\n- Single fact → str_replace or insert\n- Multiple related changes → memory_rethink\n- New topic area → create new block\n- Stale block → delete or consolidate",
|
||||||
|
"user_preferences": "Communication: Uses informal French (e.g., \"ça marche mais c'est rendu slowww\")\n\nDeployment workflow:\n- Everything runs in local Docker Swarm\n- Just deploy services like normal - don't ask for frontend code changes\n- User gets frustrated when I suggest code changes that should \"just work\""
|
||||||
|
},
|
||||||
|
"lastSeenMessageId": "message-2fa206b8-8107-4b8f-ab4d-5049d7fb58ec"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /home/swire/sociowire-code/frontend-service/sociowire-frontend
|
||||||
|
|
||||||
|
echo "=== FRONTEND DEPLOY ==="
|
||||||
|
|
||||||
|
# 1. Build JS
|
||||||
|
echo "[1/4] Building frontend..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 2. Generate unique version
|
||||||
|
VERSION="v$(date +%s)"
|
||||||
|
echo "[2/4] Version: $VERSION"
|
||||||
|
|
||||||
|
# 3. Build Docker image (no cache)
|
||||||
|
echo "[3/4] Building Docker image..."
|
||||||
|
docker build --no-cache -t sociowire/frontend:$VERSION .
|
||||||
|
|
||||||
|
# 4. Deploy
|
||||||
|
echo "[4/4] Deploying to swarm..."
|
||||||
|
docker service update --image sociowire/frontend:$VERSION sociowire_frontend
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
echo ""
|
||||||
|
echo "=== DEPLOYED: sociowire/frontend:$VERSION ==="
|
||||||
|
docker service ps sociowire_frontend --no-trunc | head -3
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "sociowire-frontend",
|
"name": "sociowire-frontend",
|
||||||
"version": "0.0.110",
|
"version": "0.0.124",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sociowire-frontend",
|
"name": "sociowire-frontend",
|
||||||
"version": "0.0.110",
|
"version": "0.0.124",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "sociowire-frontend",
|
"name": "sociowire-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.117",
|
"version": "0.0.135",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|
|
||||||
|
|
@ -213,8 +213,15 @@ export default function App() {
|
||||||
const island = await fetchIslandByUsername(u);
|
const island = await fetchIslandByUsername(u);
|
||||||
if (island) {
|
if (island) {
|
||||||
setSelectedIsland(island);
|
setSelectedIsland(island);
|
||||||
|
} else {
|
||||||
|
// Island doesn't exist - switch to Islands view to create it
|
||||||
|
setCurrentView('islands');
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error("Error fetching island:", err);
|
||||||
|
// On error, switch to Islands view
|
||||||
|
setCurrentView('islands');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// World loading placeholder
|
// World loading placeholder
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,15 @@ import {
|
||||||
decodeJwtPayload,
|
decodeJwtPayload,
|
||||||
} from "./authStorage";
|
} from "./authStorage";
|
||||||
import { parseAuthError } from "./authErrors";
|
import { parseAuthError } from "./authErrors";
|
||||||
|
import {
|
||||||
|
checkPasskey,
|
||||||
|
loginWithPasskey as passkeyLogin,
|
||||||
|
registerPasskey as passkeyRegister,
|
||||||
|
signupWithPasskey as passkeySignup,
|
||||||
|
listPasskeys,
|
||||||
|
removePasskey as passkeyRemove,
|
||||||
|
isPasskeySupported,
|
||||||
|
} from "./passkey";
|
||||||
|
|
||||||
const AuthCtx = createContext(null);
|
const AuthCtx = createContext(null);
|
||||||
|
|
||||||
|
|
@ -225,6 +234,7 @@ export function AuthProvider({ children }) {
|
||||||
|
|
||||||
const refreshTimer = useRef(null);
|
const refreshTimer = useRef(null);
|
||||||
const lastAuthStatusRef = useRef(null);
|
const lastAuthStatusRef = useRef(null);
|
||||||
|
const callbackProcessedRef = useRef(false);
|
||||||
|
|
||||||
function setAnon(msg = "") {
|
function setAnon(msg = "") {
|
||||||
setStatus("anon");
|
setStatus("anon");
|
||||||
|
|
@ -261,6 +271,7 @@ export function AuthProvider({ children }) {
|
||||||
if (mr.avatarUrl) {
|
if (mr.avatarUrl) {
|
||||||
setAvatarUrl(mr.avatarUrl);
|
setAvatarUrl(mr.avatarUrl);
|
||||||
}
|
}
|
||||||
|
const uname = mr.username || user?.username;
|
||||||
if (mr.username) {
|
if (mr.username) {
|
||||||
setUser((prev) => ({
|
setUser((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -268,6 +279,28 @@ export function AuthProvider({ children }) {
|
||||||
userId: mr.userId || prev?.userId || null,
|
userId: mr.userId || prev?.userId || null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no avatar from /api/me, try to fetch from island endpoint
|
||||||
|
console.log("[Auth] refreshProfile - mr.avatarUrl:", mr.avatarUrl, "uname:", uname);
|
||||||
|
if (!mr.avatarUrl && uname) {
|
||||||
|
try {
|
||||||
|
console.log("[Auth] Fetching avatar from island endpoint for:", uname);
|
||||||
|
const islandRes = await apiFetch(`/api/islands/by-username?username=${encodeURIComponent(uname)}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: at ? { Authorization: `Bearer ${at}` } : {},
|
||||||
|
});
|
||||||
|
console.log("[Auth] Island response status:", islandRes.status);
|
||||||
|
if (islandRes.ok) {
|
||||||
|
const islandData = await islandRes.json();
|
||||||
|
console.log("[Auth] Island data avatar_url:", islandData?.avatar_url);
|
||||||
|
if (islandData?.avatar_url) {
|
||||||
|
setAvatarUrl(islandData.avatar_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Auth] Island avatar fetch error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (typeof mr.needsUsername === "boolean") {
|
if (typeof mr.needsUsername === "boolean") {
|
||||||
setNeedsUsername(mr.needsUsername);
|
setNeedsUsername(mr.needsUsername);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -303,12 +336,16 @@ export function AuthProvider({ children }) {
|
||||||
if (!existing.refresh_token) return { ok: false, reason: "expired_no_refresh" };
|
if (!existing.refresh_token) return { ok: false, reason: "expired_no_refresh" };
|
||||||
|
|
||||||
const rr = await refreshTokens(existing.refresh_token);
|
const rr = await refreshTokens(existing.refresh_token);
|
||||||
if (!rr.ok || !rr.json?.access_token) {
|
if (!rr.ok || !rr.json?.accessToken) {
|
||||||
return { ok: false, reason: `refresh_failed_${rr.status}`, detail: rr.text };
|
return { ok: false, reason: `refresh_failed_${rr.status}`, detail: rr.text };
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = { ...existing, ...rr.json };
|
// Map backend camelCase to frontend snake_case
|
||||||
if (!merged.refresh_token) merged.refresh_token = existing.refresh_token;
|
const merged = {
|
||||||
|
...existing,
|
||||||
|
access_token: rr.json.accessToken,
|
||||||
|
refresh_token: rr.json.refreshToken || existing.refresh_token,
|
||||||
|
};
|
||||||
|
|
||||||
setAuthFromTokens(merged);
|
setAuthFromTokens(merged);
|
||||||
existing = merged;
|
existing = merged;
|
||||||
|
|
@ -335,45 +372,118 @@ export function AuthProvider({ children }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Core refresh logic - shared between interval and visibility handler
|
||||||
|
async function doTokenRefreshIfNeeded(forceRefresh = false) {
|
||||||
|
const cur = loadAuth();
|
||||||
|
if (!cur?.access_token) {
|
||||||
|
console.log("[Auth] Refresh check: no access_token");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exp = jwtExpSeconds(cur.access_token);
|
||||||
|
const now = nowSec();
|
||||||
|
const timeLeft = exp - now;
|
||||||
|
console.log("[Auth] Refresh check: token expires in", timeLeft, "seconds, has refresh_token:", !!cur.refresh_token, "forceRefresh:", forceRefresh);
|
||||||
|
|
||||||
|
if (!exp) return false;
|
||||||
|
|
||||||
|
// Refresh if token expires in less than 90 seconds OR forceRefresh is true
|
||||||
|
// On mobile, we're more aggressive because intervals get throttled
|
||||||
|
if (forceRefresh || exp <= now + 90) {
|
||||||
|
console.log("[Auth] Refreshing token...");
|
||||||
|
if (!cur.refresh_token) {
|
||||||
|
console.log("[Auth] No refresh_token available!");
|
||||||
|
// If token is already expired, logout
|
||||||
|
if (exp <= now) {
|
||||||
|
setAnon("Session expirée (pas de refresh_token).");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rr = await refreshTokens(cur.refresh_token);
|
||||||
|
console.log("[Auth] Refresh result:", rr.ok, rr.status, rr.json ? "got tokens" : "no tokens");
|
||||||
|
if (!rr.ok || !rr.json?.accessToken) {
|
||||||
|
console.log("[Auth] Refresh failed:", rr.text || rr.status);
|
||||||
|
// Only logout if token is actually expired
|
||||||
|
if (exp <= now) {
|
||||||
|
setAnon("Session expirée. Please login again.");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map backend camelCase to frontend snake_case
|
||||||
|
const merged = {
|
||||||
|
...cur,
|
||||||
|
access_token: rr.json.accessToken,
|
||||||
|
refresh_token: rr.json.refreshToken || cur.refresh_token,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[Auth] Refresh successful, new token obtained");
|
||||||
|
setAuthFromTokens(merged);
|
||||||
|
|
||||||
|
// keep verification fresh too
|
||||||
|
await refreshProfile(merged.access_token);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// lightweight: refresh verification occasionally without spamming
|
||||||
|
const tv = tokenEmailVerified(cur.access_token);
|
||||||
|
if (typeof tv === "boolean") setNeedsEmailVerification(!tv);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleAutoRefresh() {
|
function scheduleAutoRefresh() {
|
||||||
if (refreshTimer.current) {
|
if (refreshTimer.current) {
|
||||||
clearInterval(refreshTimer.current);
|
if (refreshTimer.current.cleanup) {
|
||||||
|
refreshTimer.current.cleanup();
|
||||||
|
} else {
|
||||||
|
clearInterval(refreshTimer.current);
|
||||||
|
}
|
||||||
refreshTimer.current = null;
|
refreshTimer.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshTimer.current = setInterval(async () => {
|
console.log("[Auth] scheduleAutoRefresh called, starting 30s interval");
|
||||||
const cur = loadAuth();
|
|
||||||
if (!cur?.access_token) return;
|
|
||||||
|
|
||||||
const exp = jwtExpSeconds(cur.access_token);
|
// Interval-based refresh (can be throttled when tab is minimized)
|
||||||
if (!exp) return;
|
refreshTimer.current = setInterval(() => {
|
||||||
|
doTokenRefreshIfNeeded().catch((err) => {
|
||||||
if (exp <= nowSec() + 90) {
|
console.error("[Auth] Refresh tick error:", err);
|
||||||
if (!cur.refresh_token) {
|
});
|
||||||
setAnon("Session expirée (pas de refresh_token).");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rr = await refreshTokens(cur.refresh_token);
|
|
||||||
if (!rr.ok || !rr.json?.access_token) {
|
|
||||||
setAnon("Session expirée. Please login again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = { ...cur, ...rr.json };
|
|
||||||
if (!merged.refresh_token) merged.refresh_token = cur.refresh_token;
|
|
||||||
|
|
||||||
setAuthFromTokens(merged);
|
|
||||||
|
|
||||||
// keep verification fresh too
|
|
||||||
await refreshProfile(merged.access_token);
|
|
||||||
} else {
|
|
||||||
// lightweight: refresh verification occasionally without spamming
|
|
||||||
// (every 30s interval already runs; but this call is only parsing token unless needed)
|
|
||||||
const tv = tokenEmailVerified(cur.access_token);
|
|
||||||
if (typeof tv === "boolean") setNeedsEmailVerification(!tv);
|
|
||||||
}
|
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
|
// Visibility-based refresh - runs immediately when tab becomes visible
|
||||||
|
// On mobile, always force refresh when returning to tab (intervals are throttled)
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
console.log("[Auth] Tab became visible, forcing token refresh...");
|
||||||
|
// Force refresh on mobile to ensure we always have fresh tokens
|
||||||
|
doTokenRefreshIfNeeded(true).catch((err) => {
|
||||||
|
console.error("[Auth] Visibility refresh error:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
// Also listen for focus event (more reliable on mobile)
|
||||||
|
const handleFocus = () => {
|
||||||
|
console.log("[Auth] Window focused, checking token...");
|
||||||
|
doTokenRefreshIfNeeded(true).catch((err) => {
|
||||||
|
console.error("[Auth] Focus refresh error:", err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("focus", handleFocus);
|
||||||
|
|
||||||
|
// Store cleanup function
|
||||||
|
const originalTimer = refreshTimer.current;
|
||||||
|
refreshTimer.current = {
|
||||||
|
intervalId: originalTimer,
|
||||||
|
cleanup: () => {
|
||||||
|
clearInterval(originalTimer);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
window.removeEventListener("focus", handleFocus);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restore() {
|
async function restore() {
|
||||||
|
|
@ -413,55 +523,220 @@ export function AuthProvider({ children }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(username, password) {
|
async function login(username, password) {
|
||||||
|
// Direct password login via /api/login endpoint
|
||||||
setError("");
|
setError("");
|
||||||
setLastInfo("");
|
setLastInfo("");
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
|
|
||||||
const res = await apiFetch("/api/login", {
|
if (!username || !password) {
|
||||||
method: "POST",
|
setError("Username and password required");
|
||||||
headers: { "Content-Type": "application/json" },
|
setStatus("anon");
|
||||||
body: JSON.stringify({ username, password }),
|
return { ok: false, error: "Username and password required" };
|
||||||
});
|
}
|
||||||
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
let j = null;
|
|
||||||
try {
|
try {
|
||||||
j = text ? JSON.parse(text) : null;
|
sessionStorage.removeItem("sociowire:logout");
|
||||||
} catch {
|
} catch {}
|
||||||
j = null;
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
let json = null;
|
||||||
|
try {
|
||||||
|
json = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
json = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok || !json?.accessToken) {
|
||||||
|
const errMsg = json?.error || "Invalid credentials";
|
||||||
|
setError(errMsg);
|
||||||
|
setStatus("anon");
|
||||||
|
return { ok: false, error: errMsg };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Got tokens from direct login
|
||||||
|
const t = {
|
||||||
|
access_token: json.accessToken,
|
||||||
|
refresh_token: json.refreshToken || "",
|
||||||
|
token_type: json.tokenType || "bearer",
|
||||||
|
obtained_at: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setAuthFromTokens(t, json.user?.preferred_username || json.user?.username || username);
|
||||||
|
scheduleAutoRefresh();
|
||||||
|
|
||||||
|
setUser({
|
||||||
|
username: json.user?.preferred_username || json.user?.username || username,
|
||||||
|
userId: json.user?.sub || null,
|
||||||
|
});
|
||||||
|
setStatus("auth");
|
||||||
|
|
||||||
|
try { sessionStorage.removeItem("sociowire:logout"); } catch {}
|
||||||
|
|
||||||
|
await refreshProfile(t.access_token);
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Auth] Direct login error:", err);
|
||||||
|
setError(err?.message || "Login failed");
|
||||||
|
setStatus("anon");
|
||||||
|
return { ok: false, error: err?.message || "Login failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has passkey
|
||||||
|
async function hasPasskey(username) {
|
||||||
|
if (!isPasskeySupported()) return { ok: false, hasPasskey: false, reason: "not_supported" };
|
||||||
|
return await checkPasskey(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login with passkey
|
||||||
|
async function loginWithPasskey(username) {
|
||||||
|
setError("");
|
||||||
|
setLastInfo("");
|
||||||
|
setStatus("loading");
|
||||||
|
|
||||||
|
if (!isPasskeySupported()) {
|
||||||
|
setAnon("Passkey not supported on this device");
|
||||||
|
return { ok: false, error: "not_supported" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok || !j?.access_token) {
|
const result = await passkeyLogin(username);
|
||||||
const parsed = parseAuthError(text || "", res.status);
|
|
||||||
setNeedsEmailVerification(parsed.code === "email_verification_required");
|
if (!result.ok) {
|
||||||
setAnon(parsed.message || j?.error_description || j?.error || text || "Login failed");
|
setAnon(result.error || "Passkey login failed");
|
||||||
return { ok: false, status: res.status, json: j, text };
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = { ...j, obtained_at: Date.now() };
|
// Got tokens from passkey auth
|
||||||
setAuthFromTokens(t);
|
const t = {
|
||||||
|
access_token: result.accessToken,
|
||||||
|
refresh_token: result.refreshToken,
|
||||||
|
token_type: "bearer",
|
||||||
|
obtained_at: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setAuthFromTokens(t, result.user?.username || username);
|
||||||
scheduleAutoRefresh();
|
scheduleAutoRefresh();
|
||||||
|
|
||||||
const w = await whoami(t.access_token);
|
setUser({
|
||||||
if (!w.ok || !w.authenticated) {
|
username: result.user?.username || username,
|
||||||
setAnon("Login desynced. Please login again.");
|
userId: result.user?.id || null,
|
||||||
return { ok: false, status: w.status, json: w.json, text: w.text };
|
});
|
||||||
}
|
|
||||||
|
|
||||||
setUser(w.username ? { username: w.username } : null);
|
|
||||||
setStatus("auth");
|
setStatus("auth");
|
||||||
|
|
||||||
// Clear logout flag so session can restore on reload
|
|
||||||
try { sessionStorage.removeItem("sociowire:logout"); } catch {}
|
try { sessionStorage.removeItem("sociowire:logout"); } catch {}
|
||||||
|
|
||||||
// ✅ update verified state from backend
|
|
||||||
await refreshProfile(t.access_token);
|
await refreshProfile(t.access_token);
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signup with passkey (creates new account)
|
||||||
|
async function signupWithPasskey({ username, email, firstName, lastName }) {
|
||||||
|
setError("");
|
||||||
|
setLastInfo("");
|
||||||
|
setStatus("loading");
|
||||||
|
|
||||||
|
if (!isPasskeySupported()) {
|
||||||
|
setAnon("Passkey not supported on this device");
|
||||||
|
return { ok: false, error: "not_supported" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await passkeySignup({ username, email, firstName, lastName });
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
setAnon(result.error || "Passkey signup failed");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Got tokens from passkey signup
|
||||||
|
console.log("[Auth] Passkey signup result - has accessToken:", !!result.accessToken, "has refreshToken:", !!result.refreshToken);
|
||||||
|
const t = {
|
||||||
|
access_token: result.accessToken,
|
||||||
|
refresh_token: result.refreshToken,
|
||||||
|
token_type: "bearer",
|
||||||
|
obtained_at: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[Auth] Storing tokens, refresh_token length:", result.refreshToken?.length || 0);
|
||||||
|
setAuthFromTokens(t, result.user?.username || username);
|
||||||
|
scheduleAutoRefresh();
|
||||||
|
|
||||||
|
setUser({
|
||||||
|
username: result.user?.username || username,
|
||||||
|
userId: result.user?.id || null,
|
||||||
|
});
|
||||||
|
setStatus("auth");
|
||||||
|
|
||||||
|
try { sessionStorage.removeItem("sociowire:logout"); } catch {}
|
||||||
|
|
||||||
|
await refreshProfile(t.access_token);
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a new passkey (requires being logged in)
|
||||||
|
async function registerNewPasskey(name = "Passkey") {
|
||||||
|
if (!isPasskeySupported()) {
|
||||||
|
return { ok: false, error: "Passkey not supported on this device" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force token refresh to ensure we have a valid token
|
||||||
|
const cur = loadAuth();
|
||||||
|
if (!cur?.access_token || !cur?.refresh_token) {
|
||||||
|
return { ok: false, error: "Not logged in" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always refresh before passkey registration to avoid token expiry issues
|
||||||
|
const rr = await refreshTokens(cur.refresh_token);
|
||||||
|
if (!rr.ok || !rr.json?.accessToken) {
|
||||||
|
return { ok: false, error: "Session expired. Please login again." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map backend camelCase to frontend snake_case
|
||||||
|
const merged = {
|
||||||
|
...cur,
|
||||||
|
access_token: rr.json.accessToken,
|
||||||
|
refresh_token: rr.json.refreshToken || cur.refresh_token,
|
||||||
|
};
|
||||||
|
setAuthFromTokens(merged);
|
||||||
|
|
||||||
|
return await passkeyRegister(merged.access_token, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// List user's passkeys
|
||||||
|
async function getUserPasskeys() {
|
||||||
|
const at = tokens?.access_token;
|
||||||
|
if (!at) {
|
||||||
|
return { ok: false, passkeys: [] };
|
||||||
|
}
|
||||||
|
return await listPasskeys(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a passkey
|
||||||
|
async function deletePasskey(credentialId) {
|
||||||
|
const at = tokens?.access_token;
|
||||||
|
if (!at) {
|
||||||
|
return { ok: false, error: "Not logged in" };
|
||||||
|
}
|
||||||
|
return await passkeyRemove(at, credentialId);
|
||||||
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
if (refreshTimer.current) clearInterval(refreshTimer.current);
|
if (refreshTimer.current) {
|
||||||
|
if (refreshTimer.current.cleanup) {
|
||||||
|
refreshTimer.current.cleanup();
|
||||||
|
} else {
|
||||||
|
clearInterval(refreshTimer.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
refreshTimer.current = null;
|
refreshTimer.current = null;
|
||||||
const redirectUri = window.location.origin;
|
const redirectUri = window.location.origin;
|
||||||
// Don't do SSO logout for apps subdomain - keep it isolated
|
// Don't do SSO logout for apps subdomain - keep it isolated
|
||||||
|
|
@ -707,6 +982,12 @@ export function AuthProvider({ children }) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasKeycloakCallbackParams()) {
|
if (hasKeycloakCallbackParams()) {
|
||||||
|
// Prevent double processing of callback (StrictMode, re-renders, etc.)
|
||||||
|
if (callbackProcessedRef.current) {
|
||||||
|
console.log("[Auth] Callback already processed, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callbackProcessedRef.current = true;
|
||||||
completeKeycloakLogin().catch((err) => {
|
completeKeycloakLogin().catch((err) => {
|
||||||
console.error("[Auth] SSO callback error:", err);
|
console.error("[Auth] SSO callback error:", err);
|
||||||
setAnon("SSO failed unexpectedly. Please try again.");
|
setAnon("SSO failed unexpectedly. Please try again.");
|
||||||
|
|
@ -785,6 +1066,15 @@ export function AuthProvider({ children }) {
|
||||||
setNeedsUsername,
|
setNeedsUsername,
|
||||||
clearError: () => setError(""),
|
clearError: () => setError(""),
|
||||||
clearInfo: () => setLastInfo(""),
|
clearInfo: () => setLastInfo(""),
|
||||||
|
|
||||||
|
// Passkey functions
|
||||||
|
passkeySupported: isPasskeySupported(),
|
||||||
|
hasPasskey,
|
||||||
|
loginWithPasskey,
|
||||||
|
signupWithPasskey,
|
||||||
|
registerNewPasskey,
|
||||||
|
getUserPasskeys,
|
||||||
|
deletePasskey,
|
||||||
};
|
};
|
||||||
}, [status, user, tokens, error, lastInfo, needsEmailVerification, needsUsername, avatarUrl, profileLoaded]);
|
}, [status, user, tokens, error, lastInfo, needsEmailVerification, needsUsername, avatarUrl, profileLoaded]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,445 @@
|
||||||
|
/**
|
||||||
|
* Passkey/WebAuthn utilities for SocioWire
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
async function apiFetch(path, opts = {}) {
|
||||||
|
const url = API_BASE + path;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user has a passkey registered
|
||||||
|
*/
|
||||||
|
export async function checkPasskey(username) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/passkey/check", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return { ok: res.ok, hasPasskey: !!data.hasPasskey, userExists: !!data.userExists, username: data.username || username };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Passkey] Check error:", err);
|
||||||
|
return { ok: false, hasPasskey: false, userExists: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start passkey login - returns WebAuthn options
|
||||||
|
*/
|
||||||
|
export async function startPasskeyLogin(username) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/passkey/login/start", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
return { ok: false, error: err.error || "Failed to start passkey login" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return { ok: true, options: data.options, userId: data.userId };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Passkey] Start login error:", err);
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finish passkey login - sends WebAuthn response, gets tokens
|
||||||
|
*/
|
||||||
|
export async function finishPasskeyLogin(userId, credential) {
|
||||||
|
try {
|
||||||
|
// Convert credential to JSON-serializable format
|
||||||
|
const credentialJSON = {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: arrayBufferToBase64(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
authenticatorData: arrayBufferToBase64(credential.response.authenticatorData),
|
||||||
|
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
|
||||||
|
signature: arrayBufferToBase64(credential.response.signature),
|
||||||
|
userHandle: credential.response.userHandle
|
||||||
|
? arrayBufferToBase64(credential.response.userHandle)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await apiFetch("/api/passkey/login/finish", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId, response: credentialJSON }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
return { ok: false, error: err.error || "Passkey authentication failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
accessToken: data.accessToken,
|
||||||
|
refreshToken: data.refreshToken,
|
||||||
|
expiresIn: data.expiresIn,
|
||||||
|
user: data.user,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Passkey] Finish login error:", err);
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full passkey login flow
|
||||||
|
*/
|
||||||
|
export async function loginWithPasskey(username) {
|
||||||
|
// Step 1: Start login
|
||||||
|
const startResult = await startPasskeyLogin(username);
|
||||||
|
if (!startResult.ok) {
|
||||||
|
return startResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Call WebAuthn API
|
||||||
|
try {
|
||||||
|
const options = startResult.options;
|
||||||
|
|
||||||
|
// Convert base64 to ArrayBuffer for WebAuthn
|
||||||
|
const publicKeyOptions = {
|
||||||
|
...options.publicKey,
|
||||||
|
challenge: base64ToArrayBuffer(options.publicKey.challenge),
|
||||||
|
allowCredentials: (options.publicKey.allowCredentials || []).map(cred => ({
|
||||||
|
...cred,
|
||||||
|
id: base64ToArrayBuffer(cred.id),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: publicKeyOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
return { ok: false, error: "Passkey authentication cancelled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Finish login
|
||||||
|
return await finishPasskeyLogin(startResult.userId, credential);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Passkey] WebAuthn error:", err);
|
||||||
|
if (err.name === "NotAllowedError") {
|
||||||
|
return { ok: false, error: "Passkey authentication was cancelled or timed out" };
|
||||||
|
}
|
||||||
|
return { ok: false, error: err.message || "Passkey authentication failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start passkey registration (requires auth token)
|
||||||
|
*/
|
||||||
|
export async function startPasskeyRegister(token) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/passkey/register/start", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
return { ok: false, error: err.error || "Failed to start registration" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return { ok: true, options: data.options, userId: data.userId };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Passkey] Start register error:", err);
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finish passkey registration
|
||||||
|
*/
|
||||||
|
export async function finishPasskeyRegister(token, credential, name = "Passkey") {
|
||||||
|
try {
|
||||||
|
const credentialJSON = {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: arrayBufferToBase64(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
attestationObject: arrayBufferToBase64(credential.response.attestationObject),
|
||||||
|
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await apiFetch("/api/passkey/register/finish", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name, response: credentialJSON }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
return { ok: false, error: err.error || "Failed to register passkey" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return { ok: true, name: data.name, id: data.id };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Passkey] Finish register error:", err);
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full passkey registration flow
|
||||||
|
*/
|
||||||
|
export async function registerPasskey(token, name = "Passkey") {
|
||||||
|
// Step 1: Start registration
|
||||||
|
const startResult = await startPasskeyRegister(token);
|
||||||
|
if (!startResult.ok) {
|
||||||
|
return startResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Call WebAuthn API
|
||||||
|
try {
|
||||||
|
const options = startResult.options;
|
||||||
|
|
||||||
|
const publicKeyOptions = {
|
||||||
|
...options.publicKey,
|
||||||
|
challenge: base64ToArrayBuffer(options.publicKey.challenge),
|
||||||
|
user: {
|
||||||
|
...options.publicKey.user,
|
||||||
|
id: base64ToArrayBuffer(options.publicKey.user.id),
|
||||||
|
},
|
||||||
|
excludeCredentials: (options.publicKey.excludeCredentials || []).map(cred => ({
|
||||||
|
...cred,
|
||||||
|
id: base64ToArrayBuffer(cred.id),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.create({
|
||||||
|
publicKey: publicKeyOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
return { ok: false, error: "Passkey registration cancelled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Finish registration
|
||||||
|
return await finishPasskeyRegister(token, credential, name);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Passkey] WebAuthn create error:", err);
|
||||||
|
if (err.name === "NotAllowedError") {
|
||||||
|
return { ok: false, error: "Passkey registration was cancelled" };
|
||||||
|
}
|
||||||
|
return { ok: false, error: err.message || "Passkey registration failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List user's passkeys
|
||||||
|
*/
|
||||||
|
export async function listPasskeys(token) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/passkey/list", {
|
||||||
|
method: "GET",
|
||||||
|
headers: { "Authorization": `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { ok: false, passkeys: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return { ok: true, passkeys: data.passkeys || [] };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Passkey] List error:", err);
|
||||||
|
return { ok: false, passkeys: [], error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a passkey
|
||||||
|
*/
|
||||||
|
export async function removePasskey(token, credentialId) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/passkey/remove", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ credentialId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
return { ok: false, error: err.error || "Failed to remove passkey" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Passkey] Remove error:", err);
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if WebAuthn is supported
|
||||||
|
*/
|
||||||
|
export function isPasskeySupported() {
|
||||||
|
return !!(
|
||||||
|
window.PublicKeyCredential &&
|
||||||
|
navigator.credentials &&
|
||||||
|
navigator.credentials.create &&
|
||||||
|
navigator.credentials.get
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for base64 <-> ArrayBuffer conversion
|
||||||
|
function arrayBufferToBase64(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToArrayBuffer(base64) {
|
||||||
|
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||||
|
const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const binary = atob(b64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start passkey signup - creates WebAuthn registration options
|
||||||
|
*/
|
||||||
|
export async function startPasskeySignup({ username, email, firstName, lastName }) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/passkey/signup/start", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, email, firstName, lastName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
return { ok: false, error: err.error || "Failed to start signup" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return { ok: true, options: data.options, tempUserId: data.tempUserId };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Passkey] Start signup error:", err);
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finish passkey signup - validates credential and creates account
|
||||||
|
*/
|
||||||
|
export async function finishPasskeySignup(tempUserId, credential) {
|
||||||
|
try {
|
||||||
|
// Convert credential to JSON-serializable format
|
||||||
|
const credentialJSON = {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: arrayBufferToBase64(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
attestationObject: arrayBufferToBase64(credential.response.attestationObject),
|
||||||
|
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await apiFetch("/api/passkey/signup/finish", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ tempUserId, response: credentialJSON }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
return { ok: false, error: err.error || "Signup failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
accessToken: data.accessToken,
|
||||||
|
refreshToken: data.refreshToken,
|
||||||
|
expiresIn: data.expiresIn,
|
||||||
|
user: data.user,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Passkey] Finish signup error:", err);
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full passkey signup flow
|
||||||
|
*/
|
||||||
|
export async function signupWithPasskey({ username, email, firstName, lastName }) {
|
||||||
|
// Step 1: Start signup
|
||||||
|
const startResult = await startPasskeySignup({ username, email, firstName, lastName });
|
||||||
|
if (!startResult.ok) {
|
||||||
|
return startResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Call WebAuthn API
|
||||||
|
try {
|
||||||
|
const options = startResult.options;
|
||||||
|
|
||||||
|
const publicKeyOptions = {
|
||||||
|
...options.publicKey,
|
||||||
|
challenge: base64ToArrayBuffer(options.publicKey.challenge),
|
||||||
|
user: {
|
||||||
|
...options.publicKey.user,
|
||||||
|
id: base64ToArrayBuffer(options.publicKey.user.id),
|
||||||
|
},
|
||||||
|
excludeCredentials: (options.publicKey.excludeCredentials || []).map(cred => ({
|
||||||
|
...cred,
|
||||||
|
id: base64ToArrayBuffer(cred.id),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.create({
|
||||||
|
publicKey: publicKeyOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
return { ok: false, error: "Passkey creation cancelled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Finish signup
|
||||||
|
return await finishPasskeySignup(startResult.tempUserId, credential);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Passkey] WebAuthn create error:", err);
|
||||||
|
if (err.name === "NotAllowedError") {
|
||||||
|
return { ok: false, error: "Passkey creation was cancelled" };
|
||||||
|
}
|
||||||
|
return { ok: false, error: err.message || "Passkey signup failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -443,11 +443,13 @@ body[data-theme="light"] .contacts-empty {
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-window-wrapper {
|
.chat-window-wrapper {
|
||||||
|
position: fixed !important;
|
||||||
left: 0 !important;
|
left: 0 !important;
|
||||||
right: 0 !important;
|
right: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
top: 35vh !important; /* Never go above 35% from top */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* Smooth transition for keyboard adjustment */
|
overflow: hidden;
|
||||||
transition: bottom 0.15s ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide bottom bar when chat is open to avoid z-index conflicts */
|
/* Hide bottom bar when chat is open to avoid z-index conflicts */
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,7 @@
|
||||||
/* Messages */
|
/* Messages */
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0; /* Required for flex child to scroll properly */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -467,16 +468,27 @@ body[data-theme="light"] .chat-sidebar-avatar.online::after {
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.chat-window-outer {
|
.chat-window-outer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100% !important;
|
||||||
|
max-height: 100% !important;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-window {
|
.chat-window {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
height: 50vh;
|
height: 100% !important;
|
||||||
height: 50dvh; /* Dynamic viewport height - accounts for keyboard */
|
max-height: 100% !important;
|
||||||
max-height: calc(100vh - 60px);
|
overflow: hidden;
|
||||||
max-height: calc(100dvh - 60px);
|
|
||||||
border-radius: 16px 16px 0 0;
|
border-radius: 16px 16px 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1 1 0 !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
max-height: none !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-window-outer.with-sidebar .chat-window {
|
.chat-window-outer.with-sidebar .chat-window {
|
||||||
|
|
|
||||||
|
|
@ -25,14 +25,16 @@ export default function ChatWindow({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const messagesEndRef = useRef(null);
|
const messagesContainerRef = useRef(null);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
const typingTimeout = useRef(null);
|
const typingTimeout = useRef(null);
|
||||||
|
|
||||||
// Auto-scroll to bottom on new messages
|
// Auto-scroll to bottom on new messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!minimized && messagesEndRef.current) {
|
if (!minimized && messagesContainerRef.current) {
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
|
// Scroll the messages container directly instead of using scrollIntoView
|
||||||
|
// scrollIntoView can cause parent containers to scroll on mobile
|
||||||
|
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
|
||||||
}
|
}
|
||||||
}, [messages, minimized]);
|
}, [messages, minimized]);
|
||||||
|
|
||||||
|
|
@ -163,7 +165,7 @@ export default function ChatWindow({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div className="chat-messages">
|
<div className="chat-messages" ref={messagesContainerRef}>
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="chat-empty">
|
<div className="chat-empty">
|
||||||
{t("chat.startConversation", "Start a conversation")}
|
{t("chat.startConversation", "Start a conversation")}
|
||||||
|
|
@ -208,7 +210,6 @@ export default function ChatWindow({
|
||||||
<span className="chat-typing-dot" />
|
<span className="chat-typing-dot" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
|
|
|
||||||
|
|
@ -77,13 +77,28 @@ export default function ChatWindowNew({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const messagesEndRef = useRef(null);
|
const [keyboardOffset, setKeyboardOffset] = useState(0);
|
||||||
|
const messagesContainerRef = useRef(null);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
const typingTimeout = useRef(null);
|
const typingTimeout = useRef(null);
|
||||||
|
|
||||||
|
// Handle mobile keyboard - move chat window up when keyboard opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!minimized && messagesEndRef.current) {
|
if (typeof window === "undefined" || !window.visualViewport) return;
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
|
|
||||||
|
const handleResize = () => {
|
||||||
|
// Calculate how much the viewport shrunk (keyboard height)
|
||||||
|
const keyboardHeight = window.innerHeight - window.visualViewport.height;
|
||||||
|
setKeyboardOffset(keyboardHeight > 50 ? keyboardHeight : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.visualViewport.addEventListener("resize", handleResize);
|
||||||
|
return () => window.visualViewport.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!minimized && messagesContainerRef.current) {
|
||||||
|
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
|
||||||
}
|
}
|
||||||
}, [messages, minimized]);
|
}, [messages, minimized]);
|
||||||
|
|
||||||
|
|
@ -144,7 +159,8 @@ export default function ChatWindowNew({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed bottom-4 right-4 z-[999] flex"
|
className="fixed bottom-4 right-4 z-[999] flex max-h-[65dvh] sm:max-h-none transition-[bottom] duration-150"
|
||||||
|
style={{ bottom: keyboardOffset > 0 ? keyboardOffset + 4 : undefined }}
|
||||||
variants={windowVariants}
|
variants={windowVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
|
|
@ -194,7 +210,7 @@ export default function ChatWindowNew({
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Main Window */}
|
{/* Main Window */}
|
||||||
<div className="w-80 sm:w-96 flex flex-col rounded-2xl bg-surface-900/95 backdrop-blur-xl border border-surface-700/50 shadow-2xl overflow-hidden">
|
<div className="w-80 sm:w-96 flex flex-col rounded-2xl bg-surface-900/95 backdrop-blur-xl border border-surface-700/50 shadow-2xl overflow-hidden max-h-[65dvh] sm:max-h-none">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-surface-700/50 bg-gradient-to-r from-surface-900 to-surface-800">
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-surface-700/50 bg-gradient-to-r from-surface-900 to-surface-800">
|
||||||
<Avatar src={recipientAvatar} name={recipientName} online={online} />
|
<Avatar src={recipientAvatar} name={recipientName} online={online} />
|
||||||
|
|
@ -239,7 +255,7 @@ export default function ChatWindowNew({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div className="flex-1 h-72 sm:h-80 overflow-y-auto scrollbar-hide p-4 space-y-3 bg-gradient-to-b from-surface-900/50 to-surface-900/80">
|
<div ref={messagesContainerRef} className="flex-1 min-h-0 overflow-y-auto scrollbar-hide p-4 space-y-3 bg-gradient-to-b from-surface-900/50 to-surface-900/80">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-surface-500">
|
<div className="h-full flex flex-col items-center justify-center text-surface-500">
|
||||||
<i className="fa-regular fa-comments text-4xl mb-3 opacity-40" />
|
<i className="fa-regular fa-comments text-4xl mb-3 opacity-40" />
|
||||||
|
|
@ -290,7 +306,6 @@ export default function ChatWindowNew({
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
{typing && <TypingIndicator />}
|
{typing && <TypingIndicator />}
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
|
|
|
||||||
|
|
@ -1,759 +0,0 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
|
||||||
import { registerUser, updateProfileUsername, fetchFriendRequests, toSmallImageUrl } from "../../api/client";
|
|
||||||
import AuthToast from "../Auth/AuthToast";
|
|
||||||
import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall';
|
|
||||||
import { trackEvent } from "../../utils/analytics";
|
|
||||||
import FriendsList from "../Friends/FriendsList";
|
|
||||||
|
|
||||||
const SUPPORTED_LANGS = ['en', 'fr', 'es'];
|
|
||||||
const THEMES = ['dark', 'blue', 'light'];
|
|
||||||
const LAST_SIGNUP_KEY = "sociowire:lastSignup";
|
|
||||||
|
|
||||||
function initialLetter(name = "") {
|
|
||||||
const s = (name || "").trim();
|
|
||||||
return s ? s[0].toUpperCase() : "G";
|
|
||||||
}
|
|
||||||
|
|
||||||
function truthyParam(v) {
|
|
||||||
if (!v) return false;
|
|
||||||
const x = String(v).toLowerCase().trim();
|
|
||||||
return x === "1" || x === "true" || x === "yes" || x === "ok";
|
|
||||||
}
|
|
||||||
|
|
||||||
function readLastSignup() {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LAST_SIGNUP_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
const j = JSON.parse(raw);
|
|
||||||
if (!j || typeof j !== "object") return null;
|
|
||||||
return {
|
|
||||||
username: (j.username || "").toString().trim(),
|
|
||||||
email: (j.email || "").toString().trim(),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMergedParams() {
|
|
||||||
const search = window.location.search || "";
|
|
||||||
const hash = window.location.hash || "";
|
|
||||||
|
|
||||||
const qs = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
|
||||||
|
|
||||||
let hashQs = "";
|
|
||||||
if (hash.includes("?")) hashQs = hash.slice(hash.indexOf("?") + 1);
|
|
||||||
const hs = new URLSearchParams(hashQs);
|
|
||||||
|
|
||||||
const get = (k) => {
|
|
||||||
const a = qs.get(k);
|
|
||||||
if (a != null && a !== "") return a;
|
|
||||||
const b = hs.get(k);
|
|
||||||
return b != null ? b : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return { qs, hs, get, hash };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland, onSearch, onMyLocation }) {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
authenticated,
|
|
||||||
username,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
restore,
|
|
||||||
loginWithProvider,
|
|
||||||
ssoEnabled,
|
|
||||||
avatarUrl,
|
|
||||||
profileLoaded,
|
|
||||||
needsUsername,
|
|
||||||
needsEmailVerification,
|
|
||||||
lastError,
|
|
||||||
lastInfo,
|
|
||||||
clearError,
|
|
||||||
clearInfo,
|
|
||||||
} = useAuth();
|
|
||||||
|
|
||||||
const [showLangMenu, setShowLangMenu] = useState(false);
|
|
||||||
const [showThemeMenu, setShowThemeMenu] = useState(false);
|
|
||||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
|
||||||
const langBtnRef = useRef(null);
|
|
||||||
const themeBtnRef = useRef(null);
|
|
||||||
const userMenuRef = useRef(null);
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [searchFocused, setSearchFocused] = useState(false);
|
|
||||||
|
|
||||||
const changeLanguage = (lang) => {
|
|
||||||
i18n.changeLanguage(lang);
|
|
||||||
setShowLangMenu(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Close menus when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showLangMenu && !showThemeMenu && !showUserMenu) return;
|
|
||||||
const handleClick = (e) => {
|
|
||||||
if (showLangMenu && langBtnRef.current && !langBtnRef.current.contains(e.target)) {
|
|
||||||
setShowLangMenu(false);
|
|
||||||
}
|
|
||||||
if (showThemeMenu && themeBtnRef.current && !themeBtnRef.current.contains(e.target)) {
|
|
||||||
setShowThemeMenu(false);
|
|
||||||
}
|
|
||||||
if (showUserMenu && userMenuRef.current && !userMenuRef.current.contains(e.target)) {
|
|
||||||
setShowUserMenu(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("click", handleClick);
|
|
||||||
return () => document.removeEventListener("click", handleClick);
|
|
||||||
}, [showLangMenu, showThemeMenu, showUserMenu]);
|
|
||||||
|
|
||||||
const [showAuth, setShowAuth] = useState(false);
|
|
||||||
const [mode, setMode] = useState("login");
|
|
||||||
const [showUsernameModal, setShowUsernameModal] = useState(false);
|
|
||||||
const [showFriends, setShowFriends] = useState(false);
|
|
||||||
const [pendingRequestsCount, setPendingRequestsCount] = useState(0);
|
|
||||||
const [desiredUsername, setDesiredUsername] = useState("");
|
|
||||||
const [usernameError, setUsernameError] = useState("");
|
|
||||||
const [usernameInfo, setUsernameInfo] = useState("");
|
|
||||||
const [usernameSaving, setUsernameSaving] = useState(false);
|
|
||||||
const usernamePromptTimer = useRef(null);
|
|
||||||
const [stableUsername, setStableUsername] = useState("");
|
|
||||||
|
|
||||||
const displayUsername = (() => {
|
|
||||||
const raw = (stableUsername || username || "user").toString();
|
|
||||||
if (!stableUsername && raw.includes("@")) return "user";
|
|
||||||
if (raw.length <= 12) return raw;
|
|
||||||
return raw.slice(0, 12) + "..";
|
|
||||||
})();
|
|
||||||
|
|
||||||
const [loginUser, setLoginUser] = useState("");
|
|
||||||
const [loginPass, setLoginPass] = useState("");
|
|
||||||
|
|
||||||
const [firstName, setFirstName] = useState("");
|
|
||||||
const [lastName, setLastName] = useState("");
|
|
||||||
const [regUser, setRegUser] = useState("");
|
|
||||||
const [regEmail, setRegEmail] = useState("");
|
|
||||||
const [regPass, setRegPass] = useState("");
|
|
||||||
const [signupError, setSignupError] = useState("");
|
|
||||||
const [signupInfo, setSignupInfo] = useState("");
|
|
||||||
const [signupLoading, setSignupLoading] = useState(false);
|
|
||||||
const [showInstallBtn, setShowInstallBtn] = useState(false);
|
|
||||||
const [authNotice, setAuthNotice] = useState("");
|
|
||||||
|
|
||||||
const resetPasswordUrl = useMemo(() => {
|
|
||||||
const base = (import.meta?.env?.VITE_KC_URL || "https://auth.sociowire.com").replace(/\/+$/, "");
|
|
||||||
const realm = import.meta?.env?.VITE_KC_REALM || "sociowire";
|
|
||||||
const clientId = import.meta?.env?.VITE_KC_CLIENT_ID || "sociowire-frontend";
|
|
||||||
const redirectUri = window.location.origin + window.location.pathname;
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
client_id: clientId,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
});
|
|
||||||
return `${base}/realms/${realm}/login-actions/reset-credentials?${params.toString()}`;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openModal = (nextMode) => {
|
|
||||||
setMode(nextMode);
|
|
||||||
setShowAuth(true);
|
|
||||||
setSignupError("");
|
|
||||||
trackEvent("auth_modal_open", { mode: nextMode || "login" });
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setShowAuth(false);
|
|
||||||
setLoginPass("");
|
|
||||||
setSignupError("");
|
|
||||||
setAuthNotice("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUsernameSave = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const next = (desiredUsername || "").trim();
|
|
||||||
const re = /^[A-Za-z0-9_.]{3,24}$/;
|
|
||||||
if (!re.test(next) || next.includes("@")) {
|
|
||||||
setUsernameError(t('auth.usernameHint'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUsernameSaving(true);
|
|
||||||
setUsernameError("");
|
|
||||||
setUsernameInfo("");
|
|
||||||
const res = await updateProfileUsername(next);
|
|
||||||
setUsernameSaving(false);
|
|
||||||
if (!res.ok) {
|
|
||||||
if (res.status === 409) {
|
|
||||||
setUsernameError(t('auth.usernameTaken'));
|
|
||||||
} else {
|
|
||||||
setUsernameError(res.text || t('auth.usernameUpdateError'));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUsernameInfo(t('auth.usernameSaved'));
|
|
||||||
setShowUsernameModal(false);
|
|
||||||
await restore();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (authenticated) setShowAuth(false);
|
|
||||||
}, [authenticated]);
|
|
||||||
|
|
||||||
// Fetch pending friend requests count
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authenticated) {
|
|
||||||
setPendingRequestsCount(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const loadRequests = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetchFriendRequests();
|
|
||||||
const received = (res?.requests || []).filter(r => r?.direction === 'received');
|
|
||||||
setPendingRequestsCount(received.length);
|
|
||||||
} catch (err) {
|
|
||||||
setPendingRequestsCount(0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadRequests();
|
|
||||||
const interval = setInterval(loadRequests, 30000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [authenticated]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authenticated) {
|
|
||||||
setStableUsername("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const next = (username || "").trim();
|
|
||||||
if (!next) return;
|
|
||||||
const looksLikeEmail = next.includes("@");
|
|
||||||
if (!looksLikeEmail) {
|
|
||||||
setStableUsername(next);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (stableUsername) return;
|
|
||||||
}, [authenticated, username, stableUsername]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const requiresUsername = (name) => {
|
|
||||||
if (!name) return false;
|
|
||||||
if (name.includes("@")) return true;
|
|
||||||
const re = /^[A-Za-z0-9_.]{3,24}$/;
|
|
||||||
return !re.test(name);
|
|
||||||
};
|
|
||||||
if (usernamePromptTimer.current) {
|
|
||||||
clearTimeout(usernamePromptTimer.current);
|
|
||||||
usernamePromptTimer.current = null;
|
|
||||||
}
|
|
||||||
if (loading) return;
|
|
||||||
if (!authenticated) {
|
|
||||||
setShowUsernameModal(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const shouldPrompt = !!needsUsername || requiresUsername(username);
|
|
||||||
if (shouldPrompt) {
|
|
||||||
if (!showUsernameModal) {
|
|
||||||
const base = (username || "").split("@")[0] || "";
|
|
||||||
const cleaned = base.replace(/[^A-Za-z0-9_.]/g, "").slice(0, 24);
|
|
||||||
setDesiredUsername(cleaned);
|
|
||||||
setUsernameError("");
|
|
||||||
setUsernameInfo("");
|
|
||||||
usernamePromptTimer.current = setTimeout(() => {
|
|
||||||
setShowUsernameModal(true);
|
|
||||||
}, 600);
|
|
||||||
}
|
|
||||||
} else if (showUsernameModal) {
|
|
||||||
setShowUsernameModal(false);
|
|
||||||
}
|
|
||||||
}, [authenticated, username, showUsernameModal, loading, needsUsername]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (usernamePromptTimer.current) {
|
|
||||||
clearTimeout(usernamePromptTimer.current);
|
|
||||||
usernamePromptTimer.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e) => {
|
|
||||||
const detail = e?.detail || {};
|
|
||||||
const msg = (detail.message || "").toString().trim() || t('auth.pleaseSignIn');
|
|
||||||
setAuthNotice(msg);
|
|
||||||
setMode(detail.mode === "signup" ? "signup" : "login");
|
|
||||||
setShowAuth(true);
|
|
||||||
};
|
|
||||||
window.addEventListener("sociowire:auth-required", handler);
|
|
||||||
return () => window.removeEventListener("sociowire:auth-required", handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Check if PWA install should be shown
|
|
||||||
useEffect(() => {
|
|
||||||
const checkInstall = () => {
|
|
||||||
if (isRunningAsPWA()) {
|
|
||||||
setShowInstallBtn(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowInstallBtn(canInstall());
|
|
||||||
};
|
|
||||||
checkInstall();
|
|
||||||
const handlePrompt = () => setTimeout(checkInstall, 100);
|
|
||||||
window.addEventListener('beforeinstallprompt', handlePrompt);
|
|
||||||
window.addEventListener('appinstalled', () => setShowInstallBtn(false));
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('beforeinstallprompt', handlePrompt);
|
|
||||||
window.removeEventListener('appinstalled', () => setShowInstallBtn(false));
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const { get, qs, hs, hash } = parseMergedParams();
|
|
||||||
const verified = get("verified") || get("validated") || get("email_verified") || get("emailVerified") || get("kc_verified");
|
|
||||||
const signup = get("signup");
|
|
||||||
const notice = get("notice");
|
|
||||||
const email = get("email") || get("user_email") || get("mail");
|
|
||||||
const uname = get("username") || get("user") || get("preferred_username") || get("login");
|
|
||||||
const last = readLastSignup();
|
|
||||||
|
|
||||||
if (truthyParam(verified)) {
|
|
||||||
const who = (email || uname || last?.email || last?.username || "").trim();
|
|
||||||
setSignupInfo("✅ " + t('auth.emailConfirmedMsg') + (who ? " (" + who + ")" : ""));
|
|
||||||
setMode("login");
|
|
||||||
setShowAuth(true);
|
|
||||||
const prefill = (uname || email || last?.username || last?.email || "").trim();
|
|
||||||
if (prefill) setLoginUser(prefill);
|
|
||||||
} else if (truthyParam(signup)) {
|
|
||||||
try { localStorage.removeItem(LAST_SIGNUP_KEY); } catch {}
|
|
||||||
setSignupInfo("");
|
|
||||||
setSignupError("");
|
|
||||||
setAuthNotice("");
|
|
||||||
setLoginUser("");
|
|
||||||
setMode("signup");
|
|
||||||
setShowAuth(true);
|
|
||||||
} else if (notice) {
|
|
||||||
const msg = decodeURIComponent(notice);
|
|
||||||
if (msg && msg.trim()) {
|
|
||||||
setSignupInfo(msg.trim());
|
|
||||||
setMode("login");
|
|
||||||
setShowAuth(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const touched = qs.has("verified") || qs.has("validated") || qs.has("email_verified") || qs.has("emailVerified") || qs.has("signup") || qs.has("notice") ||
|
|
||||||
hs.has("verified") || hs.has("validated") || hs.has("email_verified") || hs.has("emailVerified") || hs.has("signup") || hs.has("notice");
|
|
||||||
|
|
||||||
if (touched) {
|
|
||||||
const cleanHash = hash.includes("?") ? hash.split("?")[0] : hash;
|
|
||||||
const clean = window.location.pathname + cleanHash;
|
|
||||||
window.history.replaceState({}, "", clean);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLoginSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
trackEvent("login_submit");
|
|
||||||
const res = await login(loginUser, loginPass);
|
|
||||||
if (res?.ok) trackEvent("login_success");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSignupSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
|
|
||||||
if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) {
|
|
||||||
setSignupError(t('auth.fieldsRequired'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSignupLoading(true);
|
|
||||||
trackEvent("signup_submit");
|
|
||||||
const u = regUser.trim();
|
|
||||||
const em = regEmail.trim();
|
|
||||||
await registerUser({ username: u, email: em, password: regPass, firstName: firstName.trim(), lastName: lastName.trim() });
|
|
||||||
try { localStorage.setItem(LAST_SIGNUP_KEY, JSON.stringify({ username: u, email: em })); } catch {}
|
|
||||||
setSignupInfo("✅ " + t('auth.accountCreatedMsg') + " (" + em + ")");
|
|
||||||
trackEvent("signup_success");
|
|
||||||
setMode("login");
|
|
||||||
setLoginUser(u || em);
|
|
||||||
setLoginPass("");
|
|
||||||
} catch (err) {
|
|
||||||
setSignupError(err.message || t('auth.registrationError'));
|
|
||||||
} finally {
|
|
||||||
setSignupLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchSubmit = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (searchQuery.trim() && onSearch) {
|
|
||||||
onSearch(searchQuery.trim());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toast = useMemo(() => {
|
|
||||||
if (lastError) {
|
|
||||||
const toastType = needsEmailVerification ? "warn" : "error";
|
|
||||||
const title = needsEmailVerification ? t('auth.emailVerificationRequired') : t('auth.loginFailed');
|
|
||||||
return { type: toastType, title, message: lastError };
|
|
||||||
}
|
|
||||||
if (lastInfo) {
|
|
||||||
return { type: "success", title: "", message: lastInfo };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [lastError, lastInfo, needsEmailVerification, t]);
|
|
||||||
|
|
||||||
const themeIcon = theme === 'dark' ? 'fa-moon' : theme === 'light' ? 'fa-sun' : 'fa-droplet';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Toast area */}
|
|
||||||
{toast?.message && (
|
|
||||||
<div className="sw-toast-wrap">
|
|
||||||
<AuthToast
|
|
||||||
type={toast.type}
|
|
||||||
title={toast.title}
|
|
||||||
message={toast.message}
|
|
||||||
onClose={() => {
|
|
||||||
if (lastError && typeof clearError === "function") clearError();
|
|
||||||
if (lastInfo && typeof clearInfo === "function") clearInfo();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<header className="sw-topbar">
|
|
||||||
{/* LEFT: Logo + Brand */}
|
|
||||||
<div className="sw-topbar-left">
|
|
||||||
<div className="sw-logo">
|
|
||||||
<img src="/logo.png" alt="SocioWire" />
|
|
||||||
</div>
|
|
||||||
<div className="sw-brand">
|
|
||||||
<span className="sw-brand-name">SocioWire</span>
|
|
||||||
<span className="sw-brand-tagline">{t('topbar.wiredToLife')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CENTER: Search Bar */}
|
|
||||||
<div className={`sw-topbar-center ${searchFocused ? 'is-focused' : ''}`}>
|
|
||||||
<form onSubmit={handleSearchSubmit} className="sw-search-form">
|
|
||||||
<i className="fa-solid fa-magnifying-glass sw-search-icon" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
onFocus={() => setSearchFocused(true)}
|
|
||||||
onBlur={() => setSearchFocused(false)}
|
|
||||||
placeholder={t('map.searchPlaceholder')}
|
|
||||||
className="sw-search-input"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button type="button" className="sw-search-clear" onClick={() => setSearchQuery("")}>
|
|
||||||
<i className="fa-solid fa-times" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RIGHT: Actions */}
|
|
||||||
<div className="sw-topbar-right">
|
|
||||||
{/* My Location */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-topbar-btn sw-topbar-btn-location"
|
|
||||||
onClick={() => onMyLocation && onMyLocation()}
|
|
||||||
title={t('map.mySpot')}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-location-crosshairs" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Islands */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-topbar-btn sw-topbar-btn-islands"
|
|
||||||
onClick={() => onIslandsClick && onIslandsClick()}
|
|
||||||
title={t('nav.islands')}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-globe" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Install PWA */}
|
|
||||||
{showInstallBtn && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-topbar-btn sw-topbar-btn-install"
|
|
||||||
onClick={() => promptInstall()}
|
|
||||||
title={t('topbar.installApp')}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-download" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Theme */}
|
|
||||||
<div className="sw-topbar-dropdown" ref={themeBtnRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`sw-topbar-btn sw-topbar-btn-theme ${showThemeMenu ? 'is-active' : ''}`}
|
|
||||||
onClick={() => { setShowThemeMenu(!showThemeMenu); setShowLangMenu(false); }}
|
|
||||||
title={t(`theme.${theme}`)}
|
|
||||||
>
|
|
||||||
<i className={`fa-solid ${themeIcon}`} />
|
|
||||||
</button>
|
|
||||||
{showThemeMenu && (
|
|
||||||
<div className="sw-dropdown-menu sw-dropdown-theme">
|
|
||||||
{THEMES.map((themeKey) => (
|
|
||||||
<button
|
|
||||||
key={themeKey}
|
|
||||||
type="button"
|
|
||||||
className={`sw-dropdown-item ${theme === themeKey ? 'is-active' : ''}`}
|
|
||||||
onClick={() => { onChangeTheme && onChangeTheme(themeKey); setShowThemeMenu(false); }}
|
|
||||||
>
|
|
||||||
<i className={`fa-solid ${themeKey === 'dark' ? 'fa-moon' : themeKey === 'light' ? 'fa-sun' : 'fa-droplet'}`} />
|
|
||||||
<span>{t(`theme.${themeKey}`)}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Language */}
|
|
||||||
<div className="sw-topbar-dropdown" ref={langBtnRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`sw-topbar-btn sw-topbar-btn-lang ${showLangMenu ? 'is-active' : ''}`}
|
|
||||||
onClick={() => { setShowLangMenu(!showLangMenu); setShowThemeMenu(false); }}
|
|
||||||
title={t('language.selectLanguage')}
|
|
||||||
>
|
|
||||||
<span className="sw-lang-code">{i18n.language?.toUpperCase().slice(0, 2)}</span>
|
|
||||||
</button>
|
|
||||||
{showLangMenu && (
|
|
||||||
<div className="sw-dropdown-menu sw-dropdown-lang">
|
|
||||||
{SUPPORTED_LANGS.map((lang) => (
|
|
||||||
<button
|
|
||||||
key={lang}
|
|
||||||
type="button"
|
|
||||||
className={`sw-dropdown-item ${i18n.language?.startsWith(lang) ? 'is-active' : ''}`}
|
|
||||||
onClick={() => changeLanguage(lang)}
|
|
||||||
>
|
|
||||||
{t(`language.${lang}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Friends - only when authenticated */}
|
|
||||||
{authenticated && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`sw-topbar-btn sw-topbar-btn-friends ${showFriends ? 'is-active' : ''}`}
|
|
||||||
onClick={() => setShowFriends(!showFriends)}
|
|
||||||
title={t('topbar.friends')}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-user-group" />
|
|
||||||
{pendingRequestsCount > 0 && (
|
|
||||||
<span className="sw-badge">{pendingRequestsCount}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User / Login */}
|
|
||||||
<div className="sw-topbar-dropdown" ref={userMenuRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`sw-topbar-user ${showUserMenu ? 'is-active' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (authenticated) {
|
|
||||||
setShowUserMenu(!showUserMenu);
|
|
||||||
} else {
|
|
||||||
openModal("login");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={authenticated ? username : t('topbar.login')}
|
|
||||||
>
|
|
||||||
<div className="sw-user-avatar">
|
|
||||||
{authenticated && avatarUrl ? (
|
|
||||||
<img src={toSmallImageUrl(avatarUrl)} alt={username} />
|
|
||||||
) : authenticated ? (
|
|
||||||
<span>{initialLetter(username)}</span>
|
|
||||||
) : (
|
|
||||||
<i className="fa-solid fa-user" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{authenticated && (
|
|
||||||
<div className="sw-user-info">
|
|
||||||
<span className="sw-user-name">@{displayUsername}</span>
|
|
||||||
<span className="sw-user-status">{t('common.online')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<i className="fa-solid fa-chevron-down sw-user-chevron" />
|
|
||||||
</button>
|
|
||||||
{showUserMenu && authenticated && (
|
|
||||||
<div className="sw-dropdown-menu sw-dropdown-user">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-dropdown-item"
|
|
||||||
onClick={() => { setShowUserMenu(false); onUserIsland && onUserIsland(username); }}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-user" />
|
|
||||||
<span>{t('map.profileIsland')}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-dropdown-item sw-dropdown-logout"
|
|
||||||
onClick={() => { setShowUserMenu(false); trackEvent("logout_click"); logout(); }}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-right-from-bracket" />
|
|
||||||
<span>{t('topbar.logout')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Auth Modal */}
|
|
||||||
{showAuth && (
|
|
||||||
<div className="auth-modal-backdrop" onClick={closeModal}>
|
|
||||||
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="auth-modal-header">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={"auth-tab" + (mode === "login" ? " auth-tab-active" : "")}
|
|
||||||
onClick={() => setMode("login")}
|
|
||||||
>
|
|
||||||
{t('auth.login')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={"auth-tab" + (mode === "signup" ? " auth-tab-active" : "")}
|
|
||||||
onClick={() => setMode("signup")}
|
|
||||||
>
|
|
||||||
{t('auth.signup')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="auth-close" onClick={closeModal}>✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{signupInfo && <div className="auth-notice auth-notice-success">{signupInfo}</div>}
|
|
||||||
{authNotice && <div className="auth-notice auth-notice-warn">{authNotice}</div>}
|
|
||||||
{mode === "login" && lastError && (
|
|
||||||
<div className={"auth-notice " + (needsEmailVerification ? "auth-notice-warn" : "auth-notice-error")}>
|
|
||||||
{lastError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === "login" ? (
|
|
||||||
<form className="auth-form" onSubmit={handleLoginSubmit}>
|
|
||||||
<label>
|
|
||||||
{t('auth.usernameOrEmail')}
|
|
||||||
<input type="text" value={loginUser} onChange={(e) => setLoginUser(e.target.value)} autoComplete="username" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t('auth.password')}
|
|
||||||
<input type="password" value={loginPass} onChange={(e) => setLoginPass(e.target.value)} autoComplete="current-password" />
|
|
||||||
</label>
|
|
||||||
<button type="button" className="auth-link" onClick={() => window.open(resetPasswordUrl, "_blank", "noopener,noreferrer")}>
|
|
||||||
{t('auth.forgotPassword')}
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
|
||||||
{loading ? "..." : t('auth.signIn')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{needsEmailVerification && (
|
|
||||||
<div className="auth-notice auth-notice-warn" style={{ marginTop: 10 }}>
|
|
||||||
⚠️ {t('auth.verifyEmail')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ssoEnabled && (
|
|
||||||
<>
|
|
||||||
<div className="auth-divider">{t('auth.orContinueWith')}</div>
|
|
||||||
<div className="auth-oauth">
|
|
||||||
<button type="button" className="btn-oauth btn-oauth-google" disabled={loading}
|
|
||||||
onClick={() => { trackEvent("login_provider_click", { provider: "google" }); loginWithProvider && loginWithProvider("google"); }}>
|
|
||||||
<i className="fa-brands fa-google"></i>
|
|
||||||
<span>{t('auth.continueWithGoogle')}</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn-oauth btn-oauth-facebook" disabled={loading}
|
|
||||||
onClick={() => { trackEvent("login_provider_click", { provider: "facebook" }); loginWithProvider && loginWithProvider("facebook"); }}>
|
|
||||||
<i className="fa-brands fa-facebook-f"></i>
|
|
||||||
<span>{t('auth.continueWithFacebook')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
|
||||||
<div className="auth-row">
|
|
||||||
<label>
|
|
||||||
{t('auth.firstName')}
|
|
||||||
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t('auth.lastName')}
|
|
||||||
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
{t('auth.username')}
|
|
||||||
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t('auth.email')}
|
|
||||||
<input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t('auth.password')}
|
|
||||||
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
{signupError && <div className="auth-notice auth-notice-error">{signupError}</div>}
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
|
||||||
{signupLoading ? "..." : t('auth.createAccount')}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Username Modal */}
|
|
||||||
{showUsernameModal && (
|
|
||||||
<div className="auth-modal-backdrop" onClick={() => {}}>
|
|
||||||
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="auth-modal-header">
|
|
||||||
<div className="auth-tab auth-tab-active">{t('auth.chooseUsername')}</div>
|
|
||||||
</div>
|
|
||||||
<div className="auth-notice auth-notice-info">{t('auth.pickUsername')}</div>
|
|
||||||
{usernameError && <div className="auth-notice auth-notice-error">{usernameError}</div>}
|
|
||||||
{usernameInfo && <div className="auth-notice auth-notice-success">{usernameInfo}</div>}
|
|
||||||
<form className="auth-form" onSubmit={handleUsernameSave}>
|
|
||||||
<label>
|
|
||||||
{t('auth.username')}
|
|
||||||
<input type="text" value={desiredUsername} onChange={(e) => setDesiredUsername(e.target.value)} autoComplete="username" />
|
|
||||||
</label>
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={usernameSaving}>
|
|
||||||
{usernameSaving ? t('auth.savingUsername') : t('auth.saveUsername')}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Friends Panel */}
|
|
||||||
{showFriends && (
|
|
||||||
<div className="friends-dropdown-wrap">
|
|
||||||
<FriendsList
|
|
||||||
onSelectUser={(uname) => { setShowFriends(false); if (onUserIsland) onUserIsland(uname); }}
|
|
||||||
onClose={() => setShowFriends(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1358,7 +1358,7 @@ export default function MapView({
|
||||||
if (q) {
|
if (q) {
|
||||||
setMainFilters([]);
|
setMainFilters([]);
|
||||||
setSubFilters([]);
|
setSubFilters([]);
|
||||||
setTimeHours(336);
|
// Don't reset timeHours - keep user's current time filter
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1368,11 +1368,11 @@ export default function MapView({
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
if (!query.trim()) setForceFullPostId(null);
|
if (!query.trim()) setForceFullPostId(null);
|
||||||
|
|
||||||
// Clear ALL filters to show all search results
|
// Clear category filters to show all search results, but KEEP time filter
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
setMainFilters([]);
|
setMainFilters([]);
|
||||||
setSubFilters([]);
|
setSubFilters([]);
|
||||||
setTimeHours(336); // 2 weeks
|
// Don't reset timeHours - keep user's current time filter
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function getViewFromMap(map) {
|
||||||
radiusKm = 750;
|
radiusKm = 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
radiusKm = Math.min(Math.max(radiusKm * 1.2, 25), 2500);
|
radiusKm = Math.min(Math.max(radiusKm * 1.05, 15), 2500);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
center: [center.lng, center.lat],
|
center: [center.lng, center.lat],
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,66 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import PostCard from "./PostCardNew";
|
import PostCard from "./PostCardNew";
|
||||||
import { FeedSkeleton } from "../ui/Skeleton";
|
import { FeedSkeleton } from "../ui/Skeleton";
|
||||||
import { matchesContentFilter } from "../Filters/ContentFilters";
|
import { matchesContentFilter } from "../Filters/ContentFilters";
|
||||||
|
|
||||||
const listVariants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: { staggerChildren: 0.08 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemVariants = {
|
|
||||||
hidden: { opacity: 0, y: 20 },
|
|
||||||
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] } },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated, contentFilter = "all" }) {
|
export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated, contentFilter = "all" }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [visibleCount, setVisibleCount] = useState(1);
|
const [visibleCount, setVisibleCount] = useState(2);
|
||||||
const listRef = useRef(null);
|
const observerRef = useRef(null);
|
||||||
const sentinelRef = useRef(null);
|
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => posts.filter((p) => matchesContentFilter(p, contentFilter)),
|
() => posts.filter((p) => matchesContentFilter(p, contentFilter)),
|
||||||
[posts, contentFilter]
|
[posts, contentFilter]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Reset when filter changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisibleCount(1);
|
setVisibleCount(2);
|
||||||
}, [contentFilter]);
|
}, [contentFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Callback ref for sentinel - attaches observer when element mounts
|
||||||
const sentinel = sentinelRef.current;
|
const sentinelRef = useCallback((node) => {
|
||||||
if (!sentinel) return;
|
if (observerRef.current) {
|
||||||
const observer = new IntersectionObserver(
|
observerRef.current.disconnect();
|
||||||
(entries) => {
|
}
|
||||||
const hit = entries && entries[0];
|
if (!node) return;
|
||||||
if (!hit || !hit.isIntersecting) return;
|
|
||||||
setVisibleCount((c) => Math.min(filtered.length, c + 2));
|
observerRef.current = new IntersectionObserver(
|
||||||
},
|
(entries) => {
|
||||||
{ root: null, rootMargin: "400px 0px", threshold: 0 }
|
if (entries[0]?.isIntersecting) {
|
||||||
);
|
setVisibleCount((c) => c + 2);
|
||||||
observer.observe(sentinel);
|
}
|
||||||
return () => observer.disconnect();
|
},
|
||||||
}, [filtered.length]);
|
{ rootMargin: "100px" }
|
||||||
|
);
|
||||||
|
observerRef.current.observe(node);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (observerRef.current) {
|
||||||
|
observerRef.current.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Show skeleton loading state
|
|
||||||
if (loading && posts.length === 0) {
|
if (loading && posts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4" ref={listRef}>
|
<div className="space-y-4 p-4">
|
||||||
<FeedSkeleton count={3} />
|
<FeedSkeleton count={2} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visiblePosts = filtered.slice(0, visibleCount);
|
||||||
|
const hasMore = visibleCount < filtered.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={listRef}>
|
<div className="relative">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<div className="sticky top-14 sm:top-16 z-50 px-4 py-3 bg-surface-950/95 backdrop-blur-sm">
|
||||||
className="sticky top-14 sm:top-16 z-50 px-4 py-3
|
|
||||||
bg-gradient-to-b from-surface-950/95 via-surface-900/90 to-transparent
|
|
||||||
backdrop-blur-sm"
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-1 h-6 rounded-full bg-gradient-to-b from-accent to-purple-500" />
|
<div className="w-1 h-6 rounded-full bg-gradient-to-b from-accent to-purple-500" />
|
||||||
|
|
@ -78,85 +72,50 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{loading && (
|
{loading && (
|
||||||
<motion.div
|
<div className="flex items-center gap-2 text-xs text-accent">
|
||||||
className="flex items-center gap-2 text-xs text-accent"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-circle-notch fa-spin" />
|
<i className="fa-solid fa-circle-notch fa-spin" />
|
||||||
<span>{t('common.loading')}</span>
|
<span>{t('common.loading')}</span>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
<AnimatePresence>
|
{error && (
|
||||||
{error && (
|
<div className="mx-4 mb-4 p-4 rounded-2xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||||
<motion.div
|
<i className="fa-solid fa-exclamation-triangle mr-2" />
|
||||||
className="mx-4 mb-4 p-4 rounded-2xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm"
|
{error}
|
||||||
initial={{ opacity: 0, y: -10 }}
|
</div>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
)}
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-exclamation-triangle mr-2" />
|
|
||||||
{error}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Posts */}
|
{/* Posts */}
|
||||||
<motion.div
|
<div className="space-y-4 px-4 pb-4">
|
||||||
className="space-y-4 px-4 pb-4"
|
{visiblePosts.map((p) => (
|
||||||
variants={listVariants}
|
<div key={p.id ?? p._id}>
|
||||||
initial="hidden"
|
<PostCard
|
||||||
animate="visible"
|
post={p}
|
||||||
>
|
selected={selectedPost && (selectedPost.id ?? selectedPost._id) === (p.id ?? p._id)}
|
||||||
<AnimatePresence mode="popLayout">
|
onSelect={onSelectPost}
|
||||||
{filtered.slice(0, visibleCount).map((p) => (
|
onPostDeleted={onPostDeleted}
|
||||||
<motion.div
|
onPostUpdated={onPostUpdated}
|
||||||
key={p.id ?? p._id}
|
/>
|
||||||
variants={itemVariants}
|
</div>
|
||||||
layout
|
))}
|
||||||
>
|
</div>
|
||||||
<PostCard
|
|
||||||
post={p}
|
|
||||||
selected={selectedPost && (selectedPost.id ?? selectedPost._id) === (p.id ?? p._id)}
|
|
||||||
onSelect={onSelectPost}
|
|
||||||
onPostDeleted={onPostDeleted}
|
|
||||||
onPostUpdated={onPostUpdated}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{!loading && filtered.length === 0 && (
|
|
||||||
<motion.div
|
|
||||||
className="flex flex-col items-center justify-center py-16 text-surface-500"
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<i className="fa-regular fa-newspaper text-5xl mb-4 opacity-40" />
|
|
||||||
<p className="text-sm font-medium">{t('feed.noPosts', 'No posts found')}</p>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Load more sentinel */}
|
{/* Load more sentinel */}
|
||||||
{filtered.length > visibleCount && (
|
{hasMore && (
|
||||||
<div ref={sentinelRef} className="h-20 flex items-center justify-center">
|
<div ref={sentinelRef} className="h-16 flex items-center justify-center text-surface-500 text-sm">
|
||||||
<motion.div
|
<i className="fa-solid fa-spinner fa-spin mr-2" />
|
||||||
className="flex items-center gap-2 text-sm text-surface-500"
|
Loading...
|
||||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
</div>
|
||||||
transition={{ duration: 1.5, repeat: Infinity }}
|
)}
|
||||||
>
|
|
||||||
<i className="fa-solid fa-circle-notch fa-spin" />
|
{/* Empty state */}
|
||||||
<span>{t('feed.loadingMore', 'Loading more...')}</span>
|
{!loading && filtered.length === 0 && (
|
||||||
</motion.div>
|
<div className="flex flex-col items-center justify-center py-16 text-surface-500">
|
||||||
|
<i className="fa-regular fa-newspaper text-5xl mb-4 opacity-40" />
|
||||||
|
<p className="text-sm font-medium">{t('feed.noPosts', 'No posts found')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
|
"offline": "Offline",
|
||||||
"guest": "Guest",
|
"guest": "Guest",
|
||||||
"anon": "anon",
|
"anon": "anon",
|
||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
|
|
@ -41,7 +42,9 @@
|
||||||
"installApp": "Install App",
|
"installApp": "Install App",
|
||||||
"friends": "Friends",
|
"friends": "Friends",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"logout": "Logout"
|
"logout": "Logout",
|
||||||
|
"myIsland": "My Island",
|
||||||
|
"addPasskey": "Add Face ID / Fingerprint"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
|
|
@ -53,6 +56,16 @@
|
||||||
"signup": "Sign up",
|
"signup": "Sign up",
|
||||||
"signIn": "Sign in",
|
"signIn": "Sign in",
|
||||||
"createAccount": "Create account",
|
"createAccount": "Create account",
|
||||||
|
"welcome": "Welcome",
|
||||||
|
"recentAccounts": "Recent accounts",
|
||||||
|
"orNewAccount": "or other account",
|
||||||
|
"next": "Next",
|
||||||
|
"checking": "Checking...",
|
||||||
|
"noAccountFound": "No account found. Create a new one:",
|
||||||
|
"forRecovery": "for recovery",
|
||||||
|
"createWithPasskey": "Face ID / Fingerprint",
|
||||||
|
"createWithPassword": "Create with password",
|
||||||
|
"rememberAccount": "Remember this account",
|
||||||
"usernameOrEmail": "Username or email",
|
"usernameOrEmail": "Username or email",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"forgotPassword": "Forgot password?",
|
"forgotPassword": "Forgot password?",
|
||||||
|
|
@ -80,7 +93,23 @@
|
||||||
"verifyEmail": "Your account requires email verification. Confirm your email, then try logging in again.",
|
"verifyEmail": "Your account requires email verification. Confirm your email, then try logging in again.",
|
||||||
"fieldsRequired": "First name, last name, username, email and password are required.",
|
"fieldsRequired": "First name, last name, username, email and password are required.",
|
||||||
"registrationError": "Registration error.",
|
"registrationError": "Registration error.",
|
||||||
"pleaseSignIn": "Please sign in or create a free account to continue."
|
"pleaseSignIn": "Please sign in or create a free account to continue.",
|
||||||
|
"loginWithPasskey": "Face ID / Fingerprint",
|
||||||
|
"orOtherMethod": "or other method",
|
||||||
|
"usePassword": "Password",
|
||||||
|
"checkingPasskey": "Checking...",
|
||||||
|
"passkeyError": "Passkey authentication error",
|
||||||
|
"passwordlessSignup": "Passwordless signup!",
|
||||||
|
"passkeyAfterSignup": "Sign up with Google or Facebook, then set up a passkey for quick login.",
|
||||||
|
"signupWithGoogle": "Sign up with Google",
|
||||||
|
"signupWithFacebook": "Sign up with Facebook",
|
||||||
|
"setupPasskey": "Set up Face ID / Fingerprint",
|
||||||
|
"setupPasskeyPrompt": "Use Face ID or fingerprint for faster login next time.",
|
||||||
|
"setupPasskeyNow": "Set up now",
|
||||||
|
"skipForNow": "Later",
|
||||||
|
"passkeySetupSuccess": "Passkey set up successfully!",
|
||||||
|
"passkeySetupError": "Error setting up passkey",
|
||||||
|
"usernameRequired": "Username required"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
|
|
@ -315,7 +344,10 @@
|
||||||
"typeMessage": "Type a message...",
|
"typeMessage": "Type a message...",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"typing": "typing..."
|
"typing": "typing...",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"sent": "Sent",
|
||||||
|
"read": "Read"
|
||||||
},
|
},
|
||||||
"postModal": {
|
"postModal": {
|
||||||
"readArticle": "Read full article",
|
"readArticle": "Read full article",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"search": "Buscar",
|
"search": "Buscar",
|
||||||
"clear": "Borrar",
|
"clear": "Borrar",
|
||||||
"online": "En línea",
|
"online": "En línea",
|
||||||
|
"offline": "Desconectado",
|
||||||
"guest": "Invitado",
|
"guest": "Invitado",
|
||||||
"anon": "anon",
|
"anon": "anon",
|
||||||
"contacts": "Contactos",
|
"contacts": "Contactos",
|
||||||
|
|
@ -41,7 +42,9 @@
|
||||||
"installApp": "Instalar App",
|
"installApp": "Instalar App",
|
||||||
"friends": "Amigos",
|
"friends": "Amigos",
|
||||||
"login": "Iniciar sesión",
|
"login": "Iniciar sesión",
|
||||||
"logout": "Cerrar sesión"
|
"logout": "Cerrar sesión",
|
||||||
|
"myIsland": "Mi Isla",
|
||||||
|
"addPasskey": "Añadir Face ID / Huella"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"dark": "Oscuro",
|
"dark": "Oscuro",
|
||||||
|
|
@ -53,6 +56,16 @@
|
||||||
"signup": "Registrarse",
|
"signup": "Registrarse",
|
||||||
"signIn": "Entrar",
|
"signIn": "Entrar",
|
||||||
"createAccount": "Crear cuenta",
|
"createAccount": "Crear cuenta",
|
||||||
|
"welcome": "Bienvenido",
|
||||||
|
"recentAccounts": "Cuentas recientes",
|
||||||
|
"orNewAccount": "u otra cuenta",
|
||||||
|
"next": "Siguiente",
|
||||||
|
"checking": "Verificando...",
|
||||||
|
"noAccountFound": "No se encontró cuenta. Crea una nueva:",
|
||||||
|
"forRecovery": "para recuperación",
|
||||||
|
"createWithPasskey": "Face ID / Huella",
|
||||||
|
"createWithPassword": "Crear con contraseña",
|
||||||
|
"rememberAccount": "Recordar esta cuenta",
|
||||||
"usernameOrEmail": "Usuario o email",
|
"usernameOrEmail": "Usuario o email",
|
||||||
"password": "Contraseña",
|
"password": "Contraseña",
|
||||||
"forgotPassword": "¿Olvidaste tu contraseña?",
|
"forgotPassword": "¿Olvidaste tu contraseña?",
|
||||||
|
|
@ -80,7 +93,23 @@
|
||||||
"verifyEmail": "Tu cuenta requiere verificación de email. Confirma tu email e intenta iniciar sesión de nuevo.",
|
"verifyEmail": "Tu cuenta requiere verificación de email. Confirma tu email e intenta iniciar sesión de nuevo.",
|
||||||
"fieldsRequired": "Nombre, apellido, usuario, email y contraseña son requeridos.",
|
"fieldsRequired": "Nombre, apellido, usuario, email y contraseña son requeridos.",
|
||||||
"registrationError": "Error de registro.",
|
"registrationError": "Error de registro.",
|
||||||
"pleaseSignIn": "Inicia sesión o crea una cuenta gratuita para continuar."
|
"pleaseSignIn": "Inicia sesión o crea una cuenta gratuita para continuar.",
|
||||||
|
"loginWithPasskey": "Face ID / Huella",
|
||||||
|
"orOtherMethod": "u otro método",
|
||||||
|
"usePassword": "Contraseña",
|
||||||
|
"checkingPasskey": "Verificando...",
|
||||||
|
"passkeyError": "Error de autenticación Passkey",
|
||||||
|
"passwordlessSignup": "¡Registro sin contraseña!",
|
||||||
|
"passkeyAfterSignup": "Regístrate con Google o Facebook, luego configura un passkey para inicio rápido.",
|
||||||
|
"signupWithGoogle": "Registrarse con Google",
|
||||||
|
"signupWithFacebook": "Registrarse con Facebook",
|
||||||
|
"setupPasskey": "Configurar Face ID / Huella",
|
||||||
|
"setupPasskeyPrompt": "Usa Face ID o tu huella para iniciar sesión más rápido.",
|
||||||
|
"setupPasskeyNow": "Configurar ahora",
|
||||||
|
"skipForNow": "Más tarde",
|
||||||
|
"passkeySetupSuccess": "¡Passkey configurado con éxito!",
|
||||||
|
"passkeySetupError": "Error al configurar passkey",
|
||||||
|
"usernameRequired": "Nombre de usuario requerido"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "Todo",
|
"all": "Todo",
|
||||||
|
|
@ -311,7 +340,10 @@
|
||||||
"typeMessage": "Escribe un mensaje...",
|
"typeMessage": "Escribe un mensaje...",
|
||||||
"online": "En línea",
|
"online": "En línea",
|
||||||
"offline": "Desconectado",
|
"offline": "Desconectado",
|
||||||
"typing": "escribiendo..."
|
"typing": "escribiendo...",
|
||||||
|
"sending": "Enviando...",
|
||||||
|
"sent": "Enviado",
|
||||||
|
"read": "Leído"
|
||||||
},
|
},
|
||||||
"postModal": {
|
"postModal": {
|
||||||
"readArticle": "Leer artículo completo",
|
"readArticle": "Leer artículo completo",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"search": "Rechercher",
|
"search": "Rechercher",
|
||||||
"clear": "Effacer",
|
"clear": "Effacer",
|
||||||
"online": "En ligne",
|
"online": "En ligne",
|
||||||
|
"offline": "Hors ligne",
|
||||||
"guest": "Invité",
|
"guest": "Invité",
|
||||||
"anon": "anon",
|
"anon": "anon",
|
||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
|
|
@ -41,7 +42,9 @@
|
||||||
"installApp": "Installer l'app",
|
"installApp": "Installer l'app",
|
||||||
"friends": "Amis",
|
"friends": "Amis",
|
||||||
"login": "Connexion",
|
"login": "Connexion",
|
||||||
"logout": "Déconnexion"
|
"logout": "Déconnexion",
|
||||||
|
"myIsland": "Mon île",
|
||||||
|
"addPasskey": "Ajouter Face ID / Empreinte"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"dark": "Sombre",
|
"dark": "Sombre",
|
||||||
|
|
@ -53,6 +56,16 @@
|
||||||
"signup": "Inscription",
|
"signup": "Inscription",
|
||||||
"signIn": "Se connecter",
|
"signIn": "Se connecter",
|
||||||
"createAccount": "Créer un compte",
|
"createAccount": "Créer un compte",
|
||||||
|
"welcome": "Bienvenue",
|
||||||
|
"recentAccounts": "Comptes récents",
|
||||||
|
"orNewAccount": "ou autre compte",
|
||||||
|
"next": "Suivant",
|
||||||
|
"checking": "Vérification...",
|
||||||
|
"noAccountFound": "Aucun compte trouvé. Créez-en un nouveau:",
|
||||||
|
"forRecovery": "pour récupération",
|
||||||
|
"createWithPasskey": "Face ID / Empreinte",
|
||||||
|
"createWithPassword": "Créer avec mot de passe",
|
||||||
|
"rememberAccount": "Se souvenir de ce compte",
|
||||||
"usernameOrEmail": "Nom d'utilisateur ou email",
|
"usernameOrEmail": "Nom d'utilisateur ou email",
|
||||||
"password": "Mot de passe",
|
"password": "Mot de passe",
|
||||||
"forgotPassword": "Mot de passe oublié ?",
|
"forgotPassword": "Mot de passe oublié ?",
|
||||||
|
|
@ -80,7 +93,23 @@
|
||||||
"verifyEmail": "Votre compte nécessite une vérification email. Confirmez votre email, puis réessayez de vous connecter.",
|
"verifyEmail": "Votre compte nécessite une vérification email. Confirmez votre email, puis réessayez de vous connecter.",
|
||||||
"fieldsRequired": "Prénom, nom, nom d'utilisateur, email et mot de passe sont requis.",
|
"fieldsRequired": "Prénom, nom, nom d'utilisateur, email et mot de passe sont requis.",
|
||||||
"registrationError": "Erreur d'inscription.",
|
"registrationError": "Erreur d'inscription.",
|
||||||
"pleaseSignIn": "Connectez-vous ou créez un compte gratuit pour continuer."
|
"pleaseSignIn": "Connectez-vous ou créez un compte gratuit pour continuer.",
|
||||||
|
"loginWithPasskey": "Face ID / Empreinte",
|
||||||
|
"orOtherMethod": "ou autre méthode",
|
||||||
|
"usePassword": "Mot de passe",
|
||||||
|
"checkingPasskey": "Vérification...",
|
||||||
|
"passkeyError": "Erreur d'authentification Passkey",
|
||||||
|
"passwordlessSignup": "Inscription sans mot de passe !",
|
||||||
|
"passkeyAfterSignup": "Inscrivez-vous avec Google ou Facebook, puis configurez un passkey pour une connexion rapide.",
|
||||||
|
"signupWithGoogle": "S'inscrire avec Google",
|
||||||
|
"signupWithFacebook": "S'inscrire avec Facebook",
|
||||||
|
"setupPasskey": "Configurer Face ID / Empreinte",
|
||||||
|
"setupPasskeyPrompt": "Utilisez Face ID ou votre empreinte pour une connexion plus rapide.",
|
||||||
|
"setupPasskeyNow": "Configurer maintenant",
|
||||||
|
"skipForNow": "Plus tard",
|
||||||
|
"passkeySetupSuccess": "Passkey configuré avec succès !",
|
||||||
|
"passkeySetupError": "Erreur lors de la configuration du passkey",
|
||||||
|
"usernameRequired": "Nom d'utilisateur requis"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "Tout",
|
"all": "Tout",
|
||||||
|
|
@ -318,7 +347,10 @@
|
||||||
"typeMessage": "Tapez un message...",
|
"typeMessage": "Tapez un message...",
|
||||||
"online": "En ligne",
|
"online": "En ligne",
|
||||||
"offline": "Hors ligne",
|
"offline": "Hors ligne",
|
||||||
"typing": "en train d'écrire..."
|
"typing": "en train d'écrire...",
|
||||||
|
"sending": "Envoi...",
|
||||||
|
"sent": "Envoyé",
|
||||||
|
"read": "Lu"
|
||||||
},
|
},
|
||||||
"postModal": {
|
"postModal": {
|
||||||
"readArticle": "Lire l'article complet",
|
"readArticle": "Lire l'article complet",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue