mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Compare commits
368 Commits
@@ -0,0 +1,40 @@
|
|||||||
|
# Code Cleanup Command
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Analyze git diff between the specified branch and HEAD (defaults to main if no branch specified) and clean up code quality issues without altering functionality.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
1. **Get diff**: Run `git diff <branch>..HEAD` to see changes
|
||||||
|
2. **Identify issues**: Look for code quality problems in the diff
|
||||||
|
3. **Clean up**: Remove only the identified issues
|
||||||
|
4. **Verify**: Ensure no behavioral changes
|
||||||
|
|
||||||
|
## What to Clean Up
|
||||||
|
- **Debug artifacts**: `console.log()`, `debugger`, `print()` statements
|
||||||
|
- **Unused code**: Variables, imports, functions, parameters
|
||||||
|
- **Commented code**: Dead code blocks, TODO comments (unless active)
|
||||||
|
- **Formatting**: Inconsistent spacing, trailing whitespace
|
||||||
|
- **Temporary code**: Test values, hardcoded strings meant to be dynamic
|
||||||
|
- **Redundant code**: Duplicate logic, unnecessary intermediate variables
|
||||||
|
|
||||||
|
## What NOT to Touch
|
||||||
|
- **Functional logic**: Don't change how features work
|
||||||
|
- **API interfaces**: Keep method signatures intact
|
||||||
|
- **Configuration**: Don't modify settings or constants
|
||||||
|
- **Comments**: Keep documentation and explanatory comments
|
||||||
|
- **Error handling**: Don't remove try-catch blocks or validation
|
||||||
|
|
||||||
|
## Safety Rules
|
||||||
|
- ✅ Only modify code that appears in the git diff
|
||||||
|
- ✅ Preserve all existing functionality
|
||||||
|
- ✅ Maintain code readability and structure
|
||||||
|
- ❌ Don't refactor or optimize beyond cleanup
|
||||||
|
- ❌ Don't add new features or improvements
|
||||||
|
- ❌ Don't change variable names or function signatures
|
||||||
|
|
||||||
|
## Example
|
||||||
|
```bash
|
||||||
|
# If user specifies: "/clean-up main"
|
||||||
|
git diff main
|
||||||
|
# Clean only the issues found in this diff
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Analyze my codebase and think how it could be better organized, like a better folder or code structure.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Security Audit Command
|
||||||
|
|
||||||
|
Perform a comprehensive security audit of the FromChat **Android application** only.
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
**FromChat Android** is a 100% open source secure messaging mobile application built with:
|
||||||
|
|
||||||
|
- Kotlin Multiplatform (KMP) shared code
|
||||||
|
- Jetpack Compose UI framework
|
||||||
|
- End-to-End Encryption (NaCl, AES-GCM)
|
||||||
|
- WebSocket support for real-time features
|
||||||
|
- LiveKit integration for calls
|
||||||
|
- Local database storage (SQLite)
|
||||||
|
|
||||||
|
## Scope: Android Only
|
||||||
|
|
||||||
|
**OUT OF SCOPE:**
|
||||||
|
|
||||||
|
- Web backend (Python FastAPI, Caddy infrastructure)
|
||||||
|
- React/TypeScript frontend
|
||||||
|
|
||||||
|
**IN SCOPE:**
|
||||||
|
|
||||||
|
- Android app code (`app/android`, `app/shared/src/androidMain`)
|
||||||
|
- Shared cross-platform code (`app/shared/src/commonMain`)
|
||||||
|
- Local encryption implementation (NaCl, AES-GCM)
|
||||||
|
- Secure storage (Android Keystore, encrypted SharedPreferences)
|
||||||
|
- WebSocket client security
|
||||||
|
- Permission usage and handling
|
||||||
|
- Call security (LiveKit integration)
|
||||||
|
- Memory safety and injection attacks
|
||||||
|
- Backend
|
||||||
|
|
||||||
|
## Important Design Decisions (NOT Vulnerabilities)
|
||||||
|
|
||||||
|
When auditing, remember these are **intentional design choices**:
|
||||||
|
|
||||||
|
1. **Local message caching** - Messages downloaded and stored locally (by design)
|
||||||
|
- Messages are end-to-end encrypted at rest in local DB
|
||||||
|
- Public DMs are not encrypted (messages are public)
|
||||||
|
- Private DMs use NaCl encryption
|
||||||
|
- Cache persists across app restarts for offline access
|
||||||
|
2. **Local key storage** - Encryption keys stored on device (by design)
|
||||||
|
- Keys protected by Android Keystore (hardware-backed when available)
|
||||||
|
- Encrypted with device-specific secrets
|
||||||
|
- User data never leaves device in plaintext
|
||||||
|
- Do NOT flag key storage as critical (Keystore is production-ready)
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Provide a **clean, concise report** with:
|
||||||
|
|
||||||
|
1. **Executive Summary** - Overall rating and production readiness
|
||||||
|
2. **Security Status** - Critical issues (if any) and recommendations
|
||||||
|
3. **Security Strengths** - What's done well
|
||||||
|
4. **Component Ratings** - Table format for quick reference
|
||||||
|
5. **Architecture Review** - Data flow, encryption boundaries
|
||||||
|
6. **Threat Analysis** - Current realistic threats (e.g., rooted device, malicious APK)
|
||||||
|
7. **Recommendations** - Prioritized with time estimates
|
||||||
|
8. **Conclusion** - Clear production readiness statement
|
||||||
|
|
||||||
|
**Keep it under 500 lines** - focus on actionable findings, not verbose explanations.
|
||||||
|
|
||||||
|
## Common False Positives to Avoid
|
||||||
|
|
||||||
|
❌ **DO NOT FLAG THESE AS ISSUES:**
|
||||||
|
|
||||||
|
- Local message caching (intentional for offline access)
|
||||||
|
- Public message viewing without auth (intentional design)
|
||||||
|
- Debuggable APK (only relevant if signed/released)
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
Please analyze the recent commits in this repository and help me clean up the commit history. I want you to:
|
||||||
|
|
||||||
|
1. First, show me all commits between the target ref and HEAD using: `git log --oneline <base-branch>..HEAD`
|
||||||
|
2. Identify which commits should be squashed together (like cleanup commits, small fixes, or related changes)
|
||||||
|
3. For each group of commits you plan to squash, read their full details using: `git show <commit-hash>` to understand what changes they contain
|
||||||
|
4. Create a git-rebase-todo.txt file with your recommended rebase plan
|
||||||
|
5. Explain your reasoning for the squashing decisions, including what changes each squashed group contains. Be concise.
|
||||||
|
6. Stop and ask me if i agree with your plan.
|
||||||
|
7. Then execute the rebase using these exact commands (replace `<base-branch>` with the target branch/ref):
|
||||||
|
```bash
|
||||||
|
export GIT_SEQUENCE_EDITOR="cp git-rebase-todo.txt"
|
||||||
|
git rebase -i <base-branch>
|
||||||
|
```
|
||||||
|
8. If there are conflicts, stop and ask me to fix them.
|
||||||
|
9. Delete the `git-rebase-todo.txt` file you created.s
|
||||||
|
|
||||||
|
**Usage:** You can specify a target branch/ref as an argument. If no argument is provided, stop and ask me for the branch or ref to rebase onto.
|
||||||
|
|
||||||
|
Focus on:
|
||||||
|
- Squashing small cleanup commits into their related feature commits
|
||||||
|
- Combining related bug fixes
|
||||||
|
- Keeping meaningful feature commits separate
|
||||||
|
- Maintaining a clean, logical commit history
|
||||||
|
|
||||||
|
Please be conservative - if you're unsure about squashing something, ask me for clarification.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Run the command "npm run frontend:typecheck" and fix all errors listed in the command if there's any.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mdui": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@mdui/mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When using the browser, use this information to work better:
|
||||||
|
|
||||||
|
## Login credentials
|
||||||
|
|
||||||
|
Username: test
|
||||||
|
Password: 11111
|
||||||
|
|
||||||
|
## Server URL
|
||||||
|
|
||||||
|
http://localhost:8301
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- Do NOT start the dev server yourself, it's started automatically.
|
||||||
|
If the URL doesn't work, stop and ask me to turn on the dev server.
|
||||||
|
- Don't wait, you are slow enough to keep up with the browser.
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: Documentation rules
|
alwaysApply: true
|
||||||
alwaysApply: false
|
|
||||||
---
|
---
|
||||||
|
|
||||||
When documenting this project, follow these rules:
|
When documenting this project, follow these rules:
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When working with this project, follow these rules:
|
||||||
|
|
||||||
|
## Core Behavior
|
||||||
|
- NEVER do anything i didn't ask you for!
|
||||||
|
- Don't talk like a robot. Behave more like a human.
|
||||||
|
- Be concise and direct in responses.
|
||||||
|
- If you're unsure about something, ask for clarification instead of guessing.
|
||||||
|
|
||||||
|
## Code Quality & Principles
|
||||||
|
- Follow DRY, SOLID, YAGNI and KISS principles.
|
||||||
|
- Do NOT use old, outdated or deprecated APIs and functions.
|
||||||
|
- Use double quotes ("") for strings consistently.
|
||||||
|
- Prefer functional components over class components in React.
|
||||||
|
- Use TypeScript strictly - avoid `any` types unless absolutely necessary.
|
||||||
|
- DO NOT leave placeholders - ask me when it would be better or implement it fully.
|
||||||
|
|
||||||
|
## File Operations
|
||||||
|
- If possible, try to update files in a single edit when making multiple changes.
|
||||||
|
- Do NOT "cd" to the project directory.
|
||||||
|
- NEVER edit/delete/regenerate .env files without explicit permission.
|
||||||
|
- Don't use rg, it doesn't work anyway.
|
||||||
|
|
||||||
|
## Testing & Validation
|
||||||
|
- Do NOT "test the implementation" when you are done. The only exception is when you
|
||||||
|
need to typecheck or build the app, in that case:
|
||||||
|
- To typecheck, run `npm run frontend:typecheck`.
|
||||||
|
- To build, run `npm run frontend:build`.
|
||||||
|
- Do NOT execute other commands like "cd".
|
||||||
|
- If the typecheck passed, there's no need for checking the linter errors.
|
||||||
|
|
||||||
|
## Async Operations
|
||||||
|
- When you need a delay, use `await delay(millis);` from `@/utils/utils` in an async function. If the current function is not async, make it async.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
- NEVER create database migrations, they are auto-generated.
|
||||||
|
|
||||||
|
## Project Structure Awareness
|
||||||
|
- This is a React/TypeScript browser frontend (desktop is Compose Multiplatform in the Android/KMP repo)
|
||||||
|
- Uses MDUI components for UI
|
||||||
|
- Uses Zustand for state management
|
||||||
|
- Uses use-immer for immutable state updates
|
||||||
|
- Uses React Router for navigation
|
||||||
|
- Has WebSocket support for real-time features
|
||||||
|
- Uses encryption (tweetnacl) for security
|
||||||
|
|
||||||
|
## Performance & Efficiency
|
||||||
|
- Batch tool calls when possible to reduce latency
|
||||||
|
- Use semantic search before grep when looking for concepts
|
||||||
|
- Use TODOs for complex multi-step tasks to track progress
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
- Use SCSS modules
|
||||||
|
- Use nested styles
|
||||||
|
- Put SCSS into one folder per page
|
||||||
|
|
||||||
|
## Animations with Framer Motion
|
||||||
|
- Don't use variants if they are used only once
|
||||||
|
|
||||||
|
## Debug Mode
|
||||||
|
- When in debug mode and the issue is not yet fixed, ALWAYS end responses with `<reproduction_steps>` containing the steps to reproduce the issue and trigger logging
|
||||||
|
- When NOT in debug mode or when the issue IS fixed, escape the tag as `<reproduction_steps>` to avoid triggering it
|
||||||
|
- Never use other `<re>` tags, only `<reproduction_steps>`
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
description: Do not filter cache, API, or DB fields by matching fixed UI/placeholder English strings
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# No magic-string “sanitization” of user or message data
|
||||||
|
|
||||||
|
- **Never** strip, null out, or rewrite stored or displayed values by comparing them to hard-coded UI strings (e.g. `"Direct messages"`, `"Direct message"`, `"User 123"`, etc.). Those strings can be legitimate **usernames, display names, or message text**.
|
||||||
|
- **Prefer**: fix the source (don’t persist placeholders; use `null`/absent fields; fix the writer). If legacy bad rows exist, use an explicit **schema/version/migration** or a **documented sentinel** agreed with the backend—not substring or equality checks on natural language.
|
||||||
|
- Applies especially to: local storage/cache layers, list previews, and any code that “cleans” strings before show or read.
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
---
|
---
|
||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
When you work with UI:
|
When you work with UI:
|
||||||
|
|
||||||
1. Use MDUI components as HTML elements
|
1. Use MDUI components through the wrapper: `@/utils/material`. If the component you want to use is missing in that wrapper,
|
||||||
2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML.
|
add it. Do NOT remove anything.
|
||||||
|
3. The supporting text slot for MDUI lists is "description".
|
||||||
|
4. When working with lists/sets in states, use the "useImmer" hook.
|
||||||
|
5. Do NOT use inline styles in React components if they are static, instead write them in CSS.
|
||||||
|
Find the appropriate file to put the styles in, or create a new one.
|
||||||
|
6. In SCSS, for Material Design colors use `$color-dark-<color-name>` variables. For all colors, refer
|
||||||
|
to `frontend/src/css/_material.scss`.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
name: decrypt
|
||||||
|
description: Decrypts a DM message on localhost by granting temporary compliance extract access, running extract and decrypt CLI commands, then reverting all temporary changes. Use when the user asks to decrypt a message or run the compliance decryption workflow on localhost.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Decrypt (local debug)
|
||||||
|
|
||||||
|
End-to-end workflow for extracting and decrypting one message on **localhost**. All temporary backend access must be removed when finished.
|
||||||
|
|
||||||
|
**After decrypt:** what to do with the plaintext and files (compare, inspect, report, etc.) comes from the **current conversation** — not from this skill.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Web dev server running at `http://localhost:8301` (browser rules default).
|
||||||
|
- `compliance_keypair.txt` at the Web repo root (`Web/compliance_keypair.txt`).
|
||||||
|
- Python deps for `scripts/compliance/decryption/` (`cryptography`, etc.).
|
||||||
|
|
||||||
|
## Step 0 — Read the compliance keypair
|
||||||
|
|
||||||
|
Read `Web/compliance_keypair.txt` before any decrypt step. Confirm the **private** key line exists (line after `PRIVATE KEY` header, base64).
|
||||||
|
|
||||||
|
The decrypt CLI loads `compliance_keypair.txt` from the **current working directory**. Always run extract/decrypt with `cd` to the Web repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/Web
|
||||||
|
```
|
||||||
|
|
||||||
|
If `load_compliance_private_key` fails, the file format may need a `PRIVATE_KEY=<base64>` line (the loader expects that or a 43-char base64 line; 32-byte keys are often 44 chars with padding).
|
||||||
|
|
||||||
|
## Step 1 — Create a temporary user and capture token
|
||||||
|
|
||||||
|
Generate random credentials (username 3–20 chars: letters, digits, `-`, `_`; password 5–50 chars, no spaces):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEBUG_USER="decryptdbg$(openssl rand -hex 3)"
|
||||||
|
DEBUG_PASS="$(openssl rand -base64 12 | tr -d '/+=' | head -c 16)"
|
||||||
|
echo "user=$DEBUG_USER pass=$DEBUG_PASS"
|
||||||
|
```
|
||||||
|
|
||||||
|
Register and save **`token`** from the JSON response (not the derived login secret):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REGISTER_JSON=$(curl -sS -X POST "http://localhost:8301/api/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"${DEBUG_USER}\",\"display_name\":\"DecryptDebug\",\"password\":\"${DEBUG_PASS}\",\"confirm_password\":\"${DEBUG_PASS}\"}")
|
||||||
|
echo "$REGISTER_JSON"
|
||||||
|
DEBUG_TOKEN=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])' <<< "$REGISTER_JSON")
|
||||||
|
echo "token saved (length ${#DEBUG_TOKEN})"
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively export for the CLI: `export FROMCHAT_API_TOKEN="${DEBUG_TOKEN}"`
|
||||||
|
|
||||||
|
Save `user.id` from the same JSON if needed for debugging.
|
||||||
|
|
||||||
|
## Step 2 — Temporary compliance extract permission (marker blocks)
|
||||||
|
|
||||||
|
`GET /api/dm/compliance/extract/{message_id}` is restricted to **user id 1** in:
|
||||||
|
|
||||||
|
`backend/services/main/routes/envelope_messaging.py` (function `extract_message_for_compliance`).
|
||||||
|
|
||||||
|
Add a **temporary** bypass between searchable markers (replace `DEBUG_USERNAME` with the user from step 1):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# TEMP_COMPLIANCE_DEBUG_START — remove after decrypt debug run
|
||||||
|
_TEMP_COMPLIANCE_DEBUG_USERNAMES = {"DEBUG_USERNAME"}
|
||||||
|
# TEMP_COMPLIANCE_DEBUG_END
|
||||||
|
|
||||||
|
# Security check: only user ID 1 can access this
|
||||||
|
if current_user.id != 1 and current_user.username not in _TEMP_COMPLIANCE_DEBUG_USERNAMES:
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend will auto-reload after this change, you don't need to do anything.
|
||||||
|
|
||||||
|
To remove later: search the repo for `TEMP_COMPLIANCE_DEBUG` and delete the marker block + revert the `if` to only `current_user.id != 1`.
|
||||||
|
|
||||||
|
## Step 3 — Extract bundle (online)
|
||||||
|
|
||||||
|
From Web repo root, set variables and run (user supplies `MESSAGE_ID`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MESSAGE_ID=<message_id>
|
||||||
|
RANDOM_FOLDER="run_$(openssl rand -hex 4)"
|
||||||
|
BUNDLE_DIR="/tmp/debug_decrypt/${RANDOM_FOLDER}"
|
||||||
|
DECRYPT_DIR="/tmp/debug_decrypt/${RANDOM_FOLDER}_dec"
|
||||||
|
mkdir -p /tmp/debug_decrypt
|
||||||
|
|
||||||
|
cd /path/to/Web
|
||||||
|
python scripts/compliance/decryption/main.py extract \
|
||||||
|
--server localhost:8301 \
|
||||||
|
--http \
|
||||||
|
--token "${DEBUG_TOKEN}" \
|
||||||
|
--message-ids "${MESSAGE_ID}" \
|
||||||
|
--out-dir "${BUNDLE_DIR}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent using env (no `--token` flag):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FROMCHAT_API_TOKEN="${DEBUG_TOKEN}"
|
||||||
|
python scripts/compliance/decryption/main.py extract \
|
||||||
|
--server localhost:8301 \
|
||||||
|
--http \
|
||||||
|
--message-ids "${MESSAGE_ID}" \
|
||||||
|
--out-dir "${BUNDLE_DIR}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm `${BUNDLE_DIR}/bundle.json` exists.
|
||||||
|
|
||||||
|
**Auth flags:** use `--token` (Bearer from register/login), or `--username` + `--password` (CLI derives the login secret). Do not pass both.
|
||||||
|
|
||||||
|
## Step 4 — Decrypt bundle (offline)
|
||||||
|
|
||||||
|
Still from Web repo root (`compliance_keypair.txt` must resolve):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/compliance/decryption/main.py decrypt \
|
||||||
|
--bundle-dir "${BUNDLE_DIR}" \
|
||||||
|
--output-dir "${DECRYPT_DIR}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- `${DECRYPT_DIR}/messages/<message_id>/message.decrypted.txt` — message plaintext
|
||||||
|
- `${DECRYPT_DIR}/messages/<message_id>/files/` — decrypted attachments
|
||||||
|
- `${DECRYPT_DIR}/index.html` — HTML report
|
||||||
|
|
||||||
|
## Step 5 — Use decrypted output (conversation-driven)
|
||||||
|
|
||||||
|
Follow the **user’s request in the current chat** for what to do next (e.g. compare hashes, inspect text, verify a specific attachment). This skill stops at producing `${DECRYPT_DIR}`; do not assume a fixed post-decrypt task.
|
||||||
|
|
||||||
|
## Step 6 — Cleanup (required)
|
||||||
|
|
||||||
|
1. **Remove decrypt dirs:**
|
||||||
|
```bash
|
||||||
|
rm -rf "${BUNDLE_DIR}" "${DECRYPT_DIR}"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Delete temp user** (local SQLite default DB):
|
||||||
|
```bash
|
||||||
|
sqlite3 backend/data/database.db "DELETE FROM users WHERE username='${DEBUG_USER}';"
|
||||||
|
```
|
||||||
|
If your deployment uses another DB, delete the same username there.
|
||||||
|
|
||||||
|
3. **Remove temporary permission:** search `TEMP_COMPLIANCE_DEBUG` in the repo, delete the marker block, restore the original `if current_user.id != 1:` check, restart backend.
|
||||||
|
|
||||||
|
4. Do **not** commit `compliance_keypair.txt` or any decrypt output.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Read compliance_keypair.txt
|
||||||
|
- [ ] Register DEBUG_USER; save DEBUG_TOKEN from response
|
||||||
|
- [ ] Add TEMP_COMPLIANCE_DEBUG_* bypass; restart backend
|
||||||
|
- [ ] extract --token → bundle.json present
|
||||||
|
- [ ] decrypt → output under DECRYPT_DIR
|
||||||
|
- [ ] Post-decrypt work per conversation context
|
||||||
|
- [ ] rm -rf BUNDLE_DIR and DECRYPT_DIR
|
||||||
|
- [ ] DELETE temp user from DB
|
||||||
|
- [ ] Remove TEMP_COMPLIANCE_DEBUG markers; restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- Extract API: `GET /api/dm/compliance/extract/{message_id}` (see `bundle_extract.py`).
|
||||||
|
- Decrypt implementation: `scripts/compliance/decryption/bundle_decrypt.py`, `crypto.py`.
|
||||||
|
- Extract auth: `--token` (Bearer), env `FROMCHAT_API_TOKEN` / `FROMCHAT_TOKEN`, or `--username` + plain `--password` (CLI calls `derive_auth_secret` for login only).
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- NEVER use sleep in any command.
|
||||||
|
- Set a timeout on EVERY command that may request user input.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Shared build context ignore (used by deployment images with context: ..)
|
||||||
|
|
||||||
|
# VCS / editor
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.cursor
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Secrets / local env
|
||||||
|
.env
|
||||||
|
deployment/.env
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.vite
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
out
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ipynb_checkpoints
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# App runtime data/logs (mounted, not baked)
|
||||||
|
backend/data
|
||||||
|
backend/files
|
||||||
|
data
|
||||||
|
logs
|
||||||
|
**/logs
|
||||||
|
**/logs/**
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Deploy cache (hashes)
|
||||||
|
.deploy-cache
|
||||||
|
|
||||||
|
# Firebase cert: bind-mounted at runtime; do not send to docker build context
|
||||||
|
firebase-cert.json
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# HTTP API host.
|
||||||
|
# Example for local backend: http://localhost:8300
|
||||||
|
VITE_API_BASE_URL=http://localhost:8300
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
name: Build Electron Apps
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches: ["main", "electron"]
|
|
||||||
paths:
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
- 'frontend/electron/**'
|
|
||||||
- '.github/workflows/build.yml'
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: build-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build (${{ matrix.os }})
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Force npm to use Bash
|
|
||||||
if: runner.os == 'Windows'
|
|
||||||
run: npm config set script-shell "C:\Program Files\Git\bin\bash.exe"
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
|
|
||||||
- name: Cache npm (Linux/macOS)
|
|
||||||
if: runner.os != 'Windows'
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.npm
|
|
||||||
key: npm-${{ runner.os }}-node-24-${{ hashFiles('package.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
npm-${{ runner.os }}-node-24-
|
|
||||||
|
|
||||||
- name: Cache npm (Windows)
|
|
||||||
if: runner.os == 'Windows'
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~\AppData\Local\npm-cache
|
|
||||||
key: npm-${{ runner.os }}-node-24-${{ hashFiles('package.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
npm-${{ runner.os }}-node-24-
|
|
||||||
|
|
||||||
- name: Install root deps (no scripts)
|
|
||||||
run: npm install --ignore-scripts --no-audit --no-fund
|
|
||||||
|
|
||||||
- name: Install Electron Forge deps
|
|
||||||
run: npm run frontend:electron:dependencies
|
|
||||||
|
|
||||||
- name: Cache Electron downloads (Linux/macOS)
|
|
||||||
if: runner.os != 'Windows'
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cache/electron
|
|
||||||
~/.cache/electron-builder
|
|
||||||
key: electron-${{ runner.os }}-${{ hashFiles('package.json', 'frontend/electron/forge/package.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
electron-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Cache Electron downloads (Windows)
|
|
||||||
if: runner.os == 'Windows'
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~\AppData\Local\electron\Cache
|
|
||||||
~\AppData\Local\electron-builder\Cache
|
|
||||||
key: electron-${{ runner.os }}-${{ hashFiles('package.json', 'frontend/electron/forge/package.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
electron-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Build Electron app
|
|
||||||
shell: bash
|
|
||||||
run: npm run build:electron
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: fromchat-${{ runner.os }}
|
|
||||||
path: |
|
|
||||||
frontend/electron/forge/out/**
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Simple workflow for deploying to the self-hosted server
|
|
||||||
name: Deploy to server
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Runs on pushes targeting the default branch
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
|
||||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
|
||||||
concurrency:
|
|
||||||
group: "pages"
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: self-hosted
|
|
||||||
env:
|
|
||||||
HOME: "/root"
|
|
||||||
environment:
|
|
||||||
name: production
|
|
||||||
url: https://fromchat.toolbox-io.ru
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Set up environment
|
|
||||||
run: |
|
|
||||||
mkdir -p deployment
|
|
||||||
touch deployment/.env
|
|
||||||
cat > deployment/.env << EOF
|
|
||||||
JWT_SECRET=${{ secrets.JWT_SECRET }}
|
|
||||||
EOF
|
|
||||||
- name: Build container
|
|
||||||
run: |
|
|
||||||
cd deployment
|
|
||||||
docker compose build
|
|
||||||
- name: Set up the service
|
|
||||||
run: |
|
|
||||||
cp -f deployment/fromchat.service /etc/systemd/system/fromchat.service
|
|
||||||
systemctl daemon-reload
|
|
||||||
- name: Start the server
|
|
||||||
run: |
|
|
||||||
if ! systemctl restart fromchat && sleep 10 && systemctl status fromchat; then
|
|
||||||
journalctl --no-pager -xeu fromchat
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
+225
-5
@@ -1,6 +1,6 @@
|
|||||||
# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
|
# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
|
||||||
# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,macos,node,osx
|
# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,osx,node,macos,react,reactnative
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python,macos,node,osx
|
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python,osx,node,macos,react,reactnative
|
||||||
|
|
||||||
### macOS ###
|
### macOS ###
|
||||||
# General
|
# General
|
||||||
@@ -35,6 +35,9 @@ Temporary Items
|
|||||||
# iCloud generated files
|
# iCloud generated files
|
||||||
*.icloud
|
*.icloud
|
||||||
|
|
||||||
|
### FromChat local tools (downloaded LiveKit server binary) ###
|
||||||
|
.tools/
|
||||||
|
|
||||||
### Node ###
|
### Node ###
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
@@ -112,11 +115,18 @@ web_modules/
|
|||||||
|
|
||||||
# dotenv environment variable files
|
# dotenv environment variable files
|
||||||
.env
|
.env
|
||||||
|
.env.prod
|
||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
|
# Firebase service account JSON (backend/firebase-cert.json; bind-mounted in docker-compose)
|
||||||
|
firebase-cert.json
|
||||||
|
**/firebase-cert.json
|
||||||
|
firebase-adminsdk.json
|
||||||
|
**/firebase-adminsdk.json
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
.cache
|
.cache
|
||||||
.parcel-cache
|
.parcel-cache
|
||||||
@@ -203,6 +213,8 @@ dist/
|
|||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
@@ -353,6 +365,194 @@ poetry.toml
|
|||||||
# LSP config files
|
# LSP config files
|
||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
|
|
||||||
|
### react ###
|
||||||
|
.DS_*
|
||||||
|
**/*.backup.*
|
||||||
|
**/*.back.*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
*.sublime*
|
||||||
|
|
||||||
|
psd
|
||||||
|
thumb
|
||||||
|
sketch
|
||||||
|
|
||||||
|
### ReactNative ###
|
||||||
|
# React Native Stack Base
|
||||||
|
|
||||||
|
.expo
|
||||||
|
__generated__
|
||||||
|
|
||||||
|
### ReactNative.macOS Stack ###
|
||||||
|
# General
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
|
||||||
|
### ReactNative.Android Stack ###
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Log/OS Files
|
||||||
|
|
||||||
|
# Android Studio generated files and folders
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.apk
|
||||||
|
output.json
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
misc.xml
|
||||||
|
deploymentTargetDropDown.xml
|
||||||
|
render.experimental.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
google-services.json
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
### ReactNative.Gradle Stack ###
|
||||||
|
.gradle
|
||||||
|
**/build/
|
||||||
|
!src/**/build/
|
||||||
|
|
||||||
|
# Ignore Gradle GUI config
|
||||||
|
gradle-app.setting
|
||||||
|
|
||||||
|
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
|
||||||
|
!gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Avoid ignore Gradle wrappper properties
|
||||||
|
!gradle-wrapper.properties
|
||||||
|
|
||||||
|
# Cache of project
|
||||||
|
.gradletasknamecache
|
||||||
|
|
||||||
|
# Eclipse Gradle plugin generated files
|
||||||
|
# Eclipse Core
|
||||||
|
.project
|
||||||
|
# JDT-specific (Eclipse Java Development Tools)
|
||||||
|
.classpath
|
||||||
|
|
||||||
|
### ReactNative.Xcode Stack ###
|
||||||
|
## User settings
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
|
## Xcode 8 and earlier
|
||||||
|
*.xcscmblueprint
|
||||||
|
*.xccheckout
|
||||||
|
|
||||||
|
### ReactNative.Linux Stack ###
|
||||||
|
*~
|
||||||
|
|
||||||
|
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||||
|
.fuse_hidden*
|
||||||
|
|
||||||
|
# KDE directory preferences
|
||||||
|
.directory
|
||||||
|
|
||||||
|
# Linux trash folder which might appear on any partition or disk
|
||||||
|
.Trash-*
|
||||||
|
|
||||||
|
# .nfs files are created when an open file is removed but is still being accessed
|
||||||
|
.nfs*
|
||||||
|
|
||||||
|
### ReactNative.Node Stack ###
|
||||||
|
# Logs
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
|
||||||
|
### ReactNative.Buck Stack ###
|
||||||
|
buck-out/
|
||||||
|
.buckconfig.local
|
||||||
|
.buckd/
|
||||||
|
.buckversion
|
||||||
|
.fakebuckversion
|
||||||
|
|
||||||
### VisualStudioCode ###
|
### VisualStudioCode ###
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
@@ -372,12 +572,32 @@ pyrightconfig.json
|
|||||||
.history
|
.history
|
||||||
.ionide
|
.ionide
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,macos,node,osx
|
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,osx,node,macos,react,reactnative
|
||||||
|
|
||||||
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
|
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
|
||||||
|
|
||||||
data
|
backend/services/main/.fromchat_instance_id
|
||||||
|
backend/data
|
||||||
|
backend/files
|
||||||
.vite
|
.vite
|
||||||
*.db
|
*.db
|
||||||
package-lock.json
|
package-lock.json
|
||||||
dist-electron
|
backend/alembic/**
|
||||||
|
!backend/alembic/env.py
|
||||||
|
!backend/alembic/script.py.mako
|
||||||
|
!frontend/src/css/lib
|
||||||
|
**/*.module.scss.d.ts
|
||||||
|
|
||||||
|
.cursor/plans
|
||||||
|
tmp
|
||||||
|
compliance_keypair.txt
|
||||||
|
backend/files
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
.deploy-cache
|
||||||
|
|
||||||
|
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/
|
||||||
|
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/lib
|
||||||
|
|
||||||
|
*.module.d.scss.ts
|
||||||
Executable
+116
@@ -0,0 +1,116 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Post-push hook: opens deploy command in system's native terminal
|
||||||
|
# Cross-platform support: macOS, Linux, Windows, WSL
|
||||||
|
|
||||||
|
# Get the project root directory
|
||||||
|
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
cd "$PROJECT_ROOT" || exit 1
|
||||||
|
|
||||||
|
# Command to run in terminal (deploy.sh will load .env from project root)
|
||||||
|
COMMAND="npm run -s deploy"
|
||||||
|
|
||||||
|
# Detect OS and open appropriate terminal
|
||||||
|
detect_and_open_terminal() {
|
||||||
|
# Detect WSL
|
||||||
|
if [ -n "${WSL_DISTRO_NAME:-}" ] || [ -f /proc/version ] && grep -qi microsoft /proc/version 2>/dev/null; then
|
||||||
|
# WSL detected - try to open Windows Terminal, fallback to Linux terminals
|
||||||
|
if command -v wt.exe >/dev/null 2>&1; then
|
||||||
|
# Windows Terminal (preferred for WSL)
|
||||||
|
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
|
||||||
|
ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g")
|
||||||
|
wt.exe bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||||
|
elif command -v wsl.exe >/dev/null 2>&1; then
|
||||||
|
# Fallback: use wsl.exe to open cmd
|
||||||
|
WINDOWS_PATH=$(wslpath -w "$PROJECT_ROOT" 2>/dev/null || echo "$PROJECT_ROOT")
|
||||||
|
cmd.exe /c "start cmd /k \"cd /d $WINDOWS_PATH && $COMMAND\""
|
||||||
|
else
|
||||||
|
# Fallback to Linux terminal
|
||||||
|
open_linux_terminal
|
||||||
|
fi
|
||||||
|
# macOS
|
||||||
|
elif [ "$(uname)" = "Darwin" ]; then
|
||||||
|
# macOS - use .command file with open command
|
||||||
|
# Clean up old script files and create a new one
|
||||||
|
rm -f /tmp/post-push-deploy-*.command 2>/dev/null
|
||||||
|
SCRIPT_FILE=$(mktemp /tmp/post-push-deploy-XXXXXX.command 2>/dev/null)
|
||||||
|
if [ -z "$SCRIPT_FILE" ] || [ ! -f "$SCRIPT_FILE" ]; then
|
||||||
|
# Fallback if mktemp fails
|
||||||
|
SCRIPT_FILE="/tmp/post-push-deploy-$$.command"
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "#!/bin/bash"
|
||||||
|
echo "clear"
|
||||||
|
echo "cd '$PROJECT_ROOT'"
|
||||||
|
echo "export PS1=''"
|
||||||
|
echo "set +x"
|
||||||
|
# Export DEPLOYMENT_SERVER if it was set in the hook environment
|
||||||
|
if [ -n "$DEPLOYMENT_SERVER_VALUE" ]; then
|
||||||
|
echo "export DEPLOYMENT_SERVER='$DEPLOYMENT_SERVER_VALUE'"
|
||||||
|
fi
|
||||||
|
echo "$COMMAND"
|
||||||
|
echo "echo ''"
|
||||||
|
echo "echo 'Press Enter to close...'"
|
||||||
|
echo "read -r"
|
||||||
|
echo "osascript -e 'tell application \"Terminal\" to close front window' &"
|
||||||
|
} > "$SCRIPT_FILE"
|
||||||
|
chmod +x "$SCRIPT_FILE"
|
||||||
|
# Use open command to launch .command file - opens only one Terminal window
|
||||||
|
open "$SCRIPT_FILE"
|
||||||
|
# Windows (Git Bash or similar)
|
||||||
|
elif [ -n "${MSYSTEM:-}" ] || [ -n "${MINGW64:-}" ] || [ -n "${MINGW32:-}" ]; then
|
||||||
|
# Git Bash on Windows
|
||||||
|
if command -v wt.exe >/dev/null 2>&1; then
|
||||||
|
# Windows Terminal
|
||||||
|
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
|
||||||
|
ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g")
|
||||||
|
wt.exe bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||||
|
elif command -v cmd.exe >/dev/null 2>&1; then
|
||||||
|
# Command Prompt - convert path to Windows format
|
||||||
|
WINDOWS_PATH=$(echo "$PROJECT_ROOT" | sed 's|^/\([a-z]\)|\1:|' | sed 's|/|\\|g' | sed 's|\\|\\\\|g')
|
||||||
|
cmd.exe /c "start cmd /k \"cd /d $WINDOWS_PATH && $COMMAND\""
|
||||||
|
else
|
||||||
|
# Fallback
|
||||||
|
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
|
||||||
|
start "Deploy" bash -c "cd '$ESCAPED_PATH' && $COMMAND; exec bash"
|
||||||
|
fi
|
||||||
|
# Linux
|
||||||
|
else
|
||||||
|
open_linux_terminal
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
open_linux_terminal() {
|
||||||
|
# Escape path for use in shell commands
|
||||||
|
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
|
||||||
|
|
||||||
|
ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g")
|
||||||
|
# Try different Linux terminal emulators
|
||||||
|
if command -v gnome-terminal >/dev/null 2>&1; then
|
||||||
|
gnome-terminal -- bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||||
|
elif command -v x-terminal-emulator >/dev/null 2>&1; then
|
||||||
|
x-terminal-emulator -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||||
|
elif command -v konsole >/dev/null 2>&1; then
|
||||||
|
konsole -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||||
|
elif command -v xterm >/dev/null 2>&1; then
|
||||||
|
xterm -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||||
|
elif command -v alacritty >/dev/null 2>&1; then
|
||||||
|
alacritty -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||||
|
elif command -v kitty >/dev/null 2>&1; then
|
||||||
|
kitty bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||||
|
elif command -v tilix >/dev/null 2>&1; then
|
||||||
|
tilix -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||||
|
else
|
||||||
|
# Last resort: try to find any terminal
|
||||||
|
TERMINAL=$(command -v x-terminal-emulator gnome-terminal konsole xterm alacritty kitty tilix 2>/dev/null | head -1)
|
||||||
|
if [ -n "$TERMINAL" ]; then
|
||||||
|
"$TERMINAL" -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||||
|
else
|
||||||
|
echo "Could not find a terminal emulator. Please run manually: cd '$PROJECT_ROOT' && $COMMAND"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run in background so git push doesn't wait
|
||||||
|
# Add a small delay to ensure git push completes first
|
||||||
|
(sleep 0.5 && detect_and_open_terminal) &
|
||||||
|
|
||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
npm run frontend:typecheck
|
||||||
Vendored
+16
-2
@@ -1,6 +1,20 @@
|
|||||||
|
{
|
||||||
|
"npm.autoDetect": "off",
|
||||||
|
"files.exclude": {
|
||||||
|
".husky": true,
|
||||||
|
"build": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
"files.exclude": {
|
"files.exclude": {
|
||||||
"**/__pycache__": true,
|
"**/__pycache__": true,
|
||||||
"**/package-lock.json": true
|
"**/package-lock.json": true,
|
||||||
}
|
"**/*.module.scss.d.ts": true,
|
||||||
|
"**/.husky/_": true,
|
||||||
|
"**/.venv": true,
|
||||||
|
"**/node_modules": true
|
||||||
|
},
|
||||||
|
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||||
|
"python.terminal.activateEnvironment": false
|
||||||
}
|
}
|
||||||
Vendored
+10
-12
@@ -2,9 +2,9 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"label": "Run",
|
"label": "Frontend (Web)",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "npm run dev",
|
"command": "npm run frontend:dev",
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
},
|
},
|
||||||
@@ -16,24 +16,22 @@
|
|||||||
},
|
},
|
||||||
"group": {
|
"group": {
|
||||||
"kind": "build"
|
"kind": "build"
|
||||||
}
|
},
|
||||||
|
"isBackground": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Run (Electron)",
|
"label": "Web",
|
||||||
"type": "shell",
|
"dependsOn": ["Frontend (Web)"],
|
||||||
"command": "npm run dev:electron",
|
"dependsOrder": "parallel",
|
||||||
"options": {
|
"group": {
|
||||||
"cwd": "${workspaceFolder}"
|
"kind": "build"
|
||||||
},
|
},
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"echo": true,
|
"echo": true,
|
||||||
"reveal": "always",
|
"reveal": "always",
|
||||||
"focus": false,
|
"focus": false,
|
||||||
"panel": "shared"
|
"panel": "shared"
|
||||||
},
|
|
||||||
"group": {
|
|
||||||
"kind": "build"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# 1. Frontend production build
|
||||||
|
FROM node:24 AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 1.1. Install dependencies
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm install --ignore-scripts
|
||||||
|
|
||||||
|
# 1.2. Copy sources and configure
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ARG NODE_ENV=production
|
||||||
|
# Overridable at build time. Must be a real http(s) URL — never a compose ${...} stub.
|
||||||
|
ARG VITE_API_BASE_URL=https://api.fromchat.ru
|
||||||
|
|
||||||
|
# Fail the build if the arg is not a real URL (e.g. unexpanded Compose ${...}).
|
||||||
|
RUN if ! printf "%s" "${VITE_API_BASE_URL}" | grep -Eq '^https?://'; then \
|
||||||
|
echo "ERROR: VITE_API_BASE_URL must be an http(s) URL, got: ${VITE_API_BASE_URL}" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi && \
|
||||||
|
export NODE_ENV=production VITE_API_BASE_URL && \
|
||||||
|
npm run frontend:build
|
||||||
|
|
||||||
|
|
||||||
|
# 2. Production web static server
|
||||||
|
FROM joseluisq/static-web-server:latest AS production
|
||||||
|
|
||||||
|
# 2.1. Copy files to server root (/var/public -> /home/sws/public)
|
||||||
|
COPY --from=builder /app/build/normal/dist /var/public
|
||||||
|
|
||||||
|
# 2.2. Configure (defaults serve the image's built-in landing page instead of our app)
|
||||||
|
ENV SERVER_ROOT=/var/public
|
||||||
|
ENV SERVER_FALLBACK_PAGE=/var/public/index.html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
@@ -7,17 +7,15 @@
|
|||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
share and change all versions of a program--to make sure it remains free
|
share and change all versions of a program--to make sure it remains free
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
software for all its users.
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
|||||||
want it, that you can change the software or use pieces of it in new
|
want it, that you can change the software or use pieces of it in new
|
||||||
free programs, and that you know you can do these things.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
@@ -72,7 +60,7 @@ modification follow.
|
|||||||
|
|
||||||
0. Definitions.
|
0. Definitions.
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
works, such as semiconductor masks.
|
||||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
|||||||
the Program, the only way you could satisfy both those terms and this
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
permission to link or combine any covered work with a work licensed
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
under version 3 of the GNU General Public License into a single
|
||||||
combined work, and to convey the resulting work. The terms of this
|
combined work, and to convey the resulting work. The terms of this
|
||||||
License will continue to apply to the part which is the covered work,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
the GNU General Public License from time to time. Such new versions will
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the
|
||||||
Program specifies that a certain numbered version of the GNU General
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
Public License "or any later version" applies to it, you have the
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
Foundation. If the Program does not specify a version number of the
|
||||||
GNU General Public License, you may choose any version ever published
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
by the Free Software Foundation.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
If the Program specifies that a proxy can decide which future
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
public statement of acceptance of a version permanently authorizes you
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
to choose that version for the Program.
|
||||||
|
|
||||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||||||
Copyright (C) <year> <name of author>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU Affero General Public License as published
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU General Public License for more details.
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU Affero General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
<program> Copyright (C) <year> <name of author>
|
interface could display a "Source" link that leads users to an archive
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
of the code. There are many ways you could offer source, and different
|
||||||
This is free software, and you are welcome to redistribute it
|
solutions will be better for different programs; see section 13 for the
|
||||||
under certain conditions; type `show c' for details.
|
specific requirements.
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
<https://www.gnu.org/licenses/>.
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
||||||
|
|||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
# FromChat Web Client — Messaging Web App
|
||||||
|
|
||||||
|
[Читать на других языках: Русский](./README.md)
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/fromchat-messenger/android/main/app/android/src/main/ic_launcher-playstore.png" width="120" alt="FromChat Logo" />
|
||||||
|
|
||||||
|
**Web client for FromChat messenger**
|
||||||
|
|
||||||
|
[🌐 Web Client](https://github.com/fromchat-messenger/web) • [🖥️ Backend](https://github.com/fromchat-messenger/backend) • [📱 Android / Desktop](https://github.com/fromchat-messenger/android) • [🌍 Website](https://github.com/fromchat-messenger/site)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Description
|
||||||
|
|
||||||
|
FromChat Web is a React/TypeScript browser client for the FromChat server. The desktop client is Compose Multiplatform in the [Android/KMP repository](https://github.com/fromchat-messenger/android).
|
||||||
|
|
||||||
|
**Note:** Landing pages and legal documents live in [fromchat-messenger/site](https://github.com/fromchat-messenger/site).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Client Comparison
|
||||||
|
|
||||||
|
| Feature | Android | Web | iOS |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Messaging & profiles** | ✅ | ✅ | ❌ |
|
||||||
|
| **Voice/video calls** | ✅ | ✅ | ❌ |
|
||||||
|
| **Screen sharing** | ✅ | ✅ | ❌ |
|
||||||
|
| **Message reactions** | ❌ | ✅ | ❌ |
|
||||||
|
| **Rich attachment support** | ✅ | ❌ | ❌ |
|
||||||
|
|
||||||
|
⚠️ **iOS is temporarily not supported.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- Protected DMs (legal encryption scheme)
|
||||||
|
- Voice/video calls and screen sharing
|
||||||
|
- Message reactions
|
||||||
|
- Public chats and profiles
|
||||||
|
- Device management
|
||||||
|
- WebSocket real-time updates
|
||||||
|
- Dark mode
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Tech Stack
|
||||||
|
|
||||||
|
| Component | Notes |
|
||||||
|
|---|---|
|
||||||
|
| React 19 | UI |
|
||||||
|
| TypeScript | strict typing |
|
||||||
|
| Vite 7 | dev server & build |
|
||||||
|
| MDUI | Material Design |
|
||||||
|
| Zustand + use-immer | state |
|
||||||
|
| Motion | animations |
|
||||||
|
| TweetNaCl.js | cryptography |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Development
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Node.js 20+ (Docker image uses Node 24)
|
||||||
|
- npm
|
||||||
|
- Backend API on `http://localhost:8300` (proxied as `/api`)
|
||||||
|
|
||||||
|
### Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/fromchat-messenger/web.git
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
cp .env.example .env # if needed; install may copy it for you
|
||||||
|
npm run frontend:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8301`.
|
||||||
|
|
||||||
|
`.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# HTTP API host (Vite proxy target for /api)
|
||||||
|
VITE_API_BASE_URL=http://localhost:8300
|
||||||
|
```
|
||||||
|
|
||||||
|
In the browser the client uses same-origin `/api` (HTTP and WebSocket, e.g. `/api/chat/ws`).
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run frontend:dev # Vite on :8301
|
||||||
|
npm run frontend:typecheck # TypeScript
|
||||||
|
npm run frontend:build # typecheck + production build → build/normal
|
||||||
|
npm run frontend:preview # preview built frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├── src/
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── main/ # React app (@/)
|
||||||
|
│ │ ├── pages/ # auth, chat, profile, …
|
||||||
|
│ │ ├── core/ # API, websocket, calls, …
|
||||||
|
│ │ ├── state/ # Zustand stores
|
||||||
|
│ │ ├── utils/
|
||||||
|
│ │ └── css/ # SCSS (Material Design)
|
||||||
|
│ └── protocol/ # shared protocol (@fromchat/protocol)
|
||||||
|
├── plugins/ # Vite plugins
|
||||||
|
├── vite.config.ts
|
||||||
|
├── compose.yml # production web image (:8301→80)
|
||||||
|
├── Dockerfile
|
||||||
|
├── .env.example
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t fromchat-web:latest .
|
||||||
|
# or via compose:
|
||||||
|
docker compose --env-file .env up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Container listens on port **8301** (static server on 80 inside).
|
||||||
|
|
||||||
|
Production edge (Caddy/HAProxy) is configured via the deployment repo / backend `compose.prod.yml`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Branch for your change
|
||||||
|
2. Open a PR with a description
|
||||||
|
3. Ensure `npm run frontend:typecheck` passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
GNU Affero General Public License v3.0 — see [LICENSE](./LICENSE).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Related Repositories
|
||||||
|
|
||||||
|
- [Backend API](https://github.com/fromchat-messenger/backend)
|
||||||
|
- [Android / Desktop (KMP)](https://github.com/fromchat-messenger/android)
|
||||||
|
- [Website](https://github.com/fromchat-messenger/site)
|
||||||
|
- [Deployment](https://github.com/fromchat-messenger/deployment)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ FAQ
|
||||||
|
|
||||||
|
**Q: How do I run locally?**
|
||||||
|
A: Start the backend on `:8300`, then `npm run frontend:dev` and open `http://localhost:8301`.
|
||||||
|
|
||||||
|
**Q: Which browsers?**
|
||||||
|
A: Current Chrome, Firefox, Safari, Edge.
|
||||||
|
|
||||||
|
**Q: Do calls work on web?**
|
||||||
|
A: Yes — voice/video and screen share (server needs LiveKit).
|
||||||
|
|
||||||
|
**Q: How do I report a bug?**
|
||||||
|
A: GitHub Issues with reproduction steps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[⬆ back to top](#fromchat-web-client--messaging-web-app)**
|
||||||
@@ -1,30 +1,112 @@
|
|||||||
# FromChat
|
# FromChat Web Client — веб-приложение для обмена сообщениями
|
||||||
|
|
||||||
FromChat - полностью открытый мессенджер.
|
[Read in other languages: English](./README.en.md)
|
||||||
|
|
||||||
Демо версию можно попробовать на [сайте](http://95.165.0.162:8301).
|
_Написано ИИ. Могут быть ошибки._
|
||||||
|
|
||||||
## Содержание:
|
## 📝 Описание
|
||||||
- [Основные моменты](#highlights)
|
|
||||||
- [Использование](#usage)
|
|
||||||
- [Часто задаваемые вопросы](#faq)
|
|
||||||
- [Внос вклада](#contributing)
|
|
||||||
|
|
||||||
## Основные моменты
|
Веб-клиент FromChat — React/TypeScript приложение для браузера. Десктоп-клиент — Compose Multiplatform в [Android/KMP репозитории](https://github.com/fromchat-messenger/android).
|
||||||
- Написан на HTML, SCSS, TypeScript (фронтэнд) и Python (бэкэнд).
|
|
||||||
- 100% открытый исходный код позволяет настроить вид и поведение мессенджера полностью под себя.
|
|
||||||
|
|
||||||
## Использование
|
## Развернуть в 1 клик
|
||||||
_В разработке._
|
|
||||||
|
|
||||||
## Часто задаваемые вопросы
|
```bash
|
||||||
<!--
|
docker run -d --restart always -p 8301:80 fromchat/web:latest
|
||||||
Вопрос: __Чему равно 2+2?__
|
```
|
||||||
Ответ: __4__
|
|
||||||
Вопрос: __Какая цитата Джейсона Стетхема на ваш взгляд является лучшей?__
|
|
||||||
Ответ: __"Одна ошибка, и ты ошибся."__
|
|
||||||
-->
|
|
||||||
_В разработке._
|
|
||||||
|
|
||||||
## Внос вклада
|
## ✨ Возможности
|
||||||
Внести свой вклад в разработку FromChat можно при помощи pull request или вступления в нашу команду. Заявку на вступление в команду можно оставить [здесь](https://t.me/denis0001-dev).
|
|
||||||
|
- Защищённые личные сообщения (легальная схема шифрования)
|
||||||
|
- Голосовые/видеозвонки и демонстрация экрана
|
||||||
|
- Реакции на сообщения
|
||||||
|
- Публичные чаты и профили
|
||||||
|
- Управление устройствами
|
||||||
|
- WebSocket для реал-тайма
|
||||||
|
- Тёмный режим
|
||||||
|
|
||||||
|
## 🏗️ Технологический стек
|
||||||
|
|
||||||
|
| Компонент | Примечание |
|
||||||
|
|---|---|
|
||||||
|
| React 19 | UI |
|
||||||
|
| TypeScript | строгая типизация |
|
||||||
|
| Vite 7 | dev-сервер и сборка |
|
||||||
|
| MDUI | Material Design |
|
||||||
|
| Zustand + use-immer | состояние |
|
||||||
|
| Motion | анимации |
|
||||||
|
| TweetNaCl.js | криптография |
|
||||||
|
|
||||||
|
## 🔧 Разработка
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- Node.js 20+ (для Docker-образа используется Node 24)
|
||||||
|
- npm
|
||||||
|
- Backend API на `http://localhost:8300` (проксируется через `/api`)
|
||||||
|
|
||||||
|
### Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/fromchat-messenger/web.git
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
cp .env.example .env # при необходимости; install может скопировать сам
|
||||||
|
npm run frontend:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте `http://localhost:8301`.
|
||||||
|
|
||||||
|
### Команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run frontend:dev # Vite на :8301
|
||||||
|
npm run frontend:typecheck # TypeScript
|
||||||
|
npm run frontend:build # typecheck + production build → build/normal
|
||||||
|
npm run frontend:preview # preview собранного фронта
|
||||||
|
```
|
||||||
|
|
||||||
|
### Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├── src/
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── main/ # React-приложение (@/)
|
||||||
|
│ │ ├── pages/ # auth, chat, profile, …
|
||||||
|
│ │ ├── core/ # API, websocket, calls, …
|
||||||
|
│ │ ├── state/ # Zustand stores
|
||||||
|
│ │ ├── utils/
|
||||||
|
│ │ └── css/ # SCSS (Material Design)
|
||||||
|
│ └── protocol/ # общий протокол (@fromchat/protocol)
|
||||||
|
├── plugins/ # Vite-плагины
|
||||||
|
├── vite.config.ts
|
||||||
|
├── compose.yml # production-образ веба (:8301→80)
|
||||||
|
├── Dockerfile
|
||||||
|
├── .env.example
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Контейнер слушает порт **8301** (внутри nginx/static-server на 80).
|
||||||
|
|
||||||
|
## 🤝 Внесение вклада
|
||||||
|
|
||||||
|
1. Создайте ветку под изменение
|
||||||
|
2. Отправьте PR с описанием
|
||||||
|
3. Проверьте типы: `npm run frontend:typecheck`
|
||||||
|
|
||||||
|
## 📄 Лицензия
|
||||||
|
|
||||||
|
GNU Affero General Public License v3.0 — см. [LICENSE](./LICENSE).
|
||||||
|
|
||||||
|
## 🔗 Связанные репозитории
|
||||||
|
|
||||||
|
- [Backend API](https://github.com/fromchat-messenger/backend)
|
||||||
|
- [Android / Desktop (KMP)](https://github.com/fromchat-messenger/android)
|
||||||
|
- [Website](https://github.com/fromchat-messenger/site)
|
||||||
|
- [Deployment](https://github.com/fromchat-messenger/deployment)
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
from routes import account, messaging, profile
|
|
||||||
|
|
||||||
# Инициализация FastAPI
|
|
||||||
app = FastAPI(title="PixelChat")
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"], # В продакшене замените на нужные домены
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Routes
|
|
||||||
app.include_router(account.router)
|
|
||||||
app.include_router(messaging.router)
|
|
||||||
app.include_router(profile.router)
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
DATABASE_URL = "sqlite:///./data/database.db"
|
|
||||||
JWT_ALGORITHM = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
|
||||||
OWNER_USERNAME = "denis0001-dev"
|
|
||||||
JWT_SECRET_KEY = os.getenv("JWT_SECRET")
|
|
||||||
|
|
||||||
if not JWT_SECRET_KEY:
|
|
||||||
raise ValueError("JWT secret key empty")
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import os
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from constants import DATABASE_URL
|
|
||||||
|
|
||||||
# Ensure data directory exists
|
|
||||||
os.makedirs("data", exist_ok=True)
|
|
||||||
|
|
||||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from utils import *
|
|
||||||
from models import *
|
|
||||||
from db import SessionLocal
|
|
||||||
|
|
||||||
security = HTTPBearer()
|
|
||||||
|
|
||||||
# Зависимость для получения сессии БД
|
|
||||||
def get_db():
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# Зависимость для получения текущего пользователя
|
|
||||||
def get_current_user(
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
) -> User:
|
|
||||||
token = credentials.credentials
|
|
||||||
payload = verify_token(token)
|
|
||||||
if not payload:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid or expired token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
user = db.query(User).filter(User.id == payload["user_id"]).first()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="User not found",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
return user
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from constants import *
|
|
||||||
from db import *
|
|
||||||
from models import *
|
|
||||||
from validation import *
|
|
||||||
from utils import *
|
|
||||||
from dependencies import *
|
|
||||||
from app import *
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, text
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from datetime import datetime
|
|
||||||
from db import engine
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
# Модели базы данных
|
|
||||||
class User(Base):
|
|
||||||
__tablename__ = "user"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
|
||||||
password_hash = Column(String(200), nullable=False)
|
|
||||||
profile_picture = Column(String(255), nullable=True)
|
|
||||||
bio = Column(Text, nullable=True)
|
|
||||||
online = Column(Boolean, default=False)
|
|
||||||
last_seen = Column(DateTime, default=datetime.now)
|
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
|
||||||
messages = relationship("Message", back_populates="author", lazy="select")
|
|
||||||
|
|
||||||
|
|
||||||
class Message(Base):
|
|
||||||
__tablename__ = "message"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
content = Column(Text, nullable=False)
|
|
||||||
timestamp = Column(DateTime, default=datetime.now)
|
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
|
||||||
is_read = Column(Boolean, default=False)
|
|
||||||
reply_to_id = Column(Integer, ForeignKey("message.id"), nullable=True)
|
|
||||||
is_edited = Column(Boolean, default=False)
|
|
||||||
|
|
||||||
author = relationship("User", back_populates="messages")
|
|
||||||
reply_to = relationship("Message", remote_side=[id])
|
|
||||||
|
|
||||||
|
|
||||||
# Pydantic модели
|
|
||||||
class LoginRequest(BaseModel):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
confirm_password: str
|
|
||||||
|
|
||||||
|
|
||||||
class SendMessageRequest(BaseModel):
|
|
||||||
content: str
|
|
||||||
|
|
||||||
|
|
||||||
class EditMessageRequest(BaseModel):
|
|
||||||
content: str
|
|
||||||
|
|
||||||
|
|
||||||
class ReplyMessageRequest(BaseModel):
|
|
||||||
content: str
|
|
||||||
reply_to_id: int
|
|
||||||
|
|
||||||
|
|
||||||
class DeleteMessageRequest(BaseModel):
|
|
||||||
message_id: int
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateBioRequest(BaseModel):
|
|
||||||
bio: str
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfileResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
username: str
|
|
||||||
profile_picture: str | None
|
|
||||||
bio: str | None
|
|
||||||
online: bool
|
|
||||||
last_seen: datetime
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class MessageResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
content: str
|
|
||||||
timestamp: datetime
|
|
||||||
is_author: bool
|
|
||||||
is_read: bool
|
|
||||||
username: str
|
|
||||||
profile_picture: str | None
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
# Создание таблиц
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
PyJWT>=2.8.0
|
|
||||||
fastapi[standard]>=0.116.1
|
|
||||||
pydantic>=2.11.7
|
|
||||||
sqlalchemy>=2.0.43
|
|
||||||
bcrypt>=4.3.0
|
|
||||||
websockets>=15.0.1
|
|
||||||
Pillow>=10.0.0
|
|
||||||
python-multipart>=0.0.6
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from routes.messaging import convert_message
|
|
||||||
from constants import OWNER_USERNAME
|
|
||||||
from dependencies import get_current_user, get_db
|
|
||||||
from models import LoginRequest, RegisterRequest, User
|
|
||||||
from utils import create_token, get_password_hash, verify_password
|
|
||||||
from validation import is_valid_password, is_valid_username
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
def convert_user(user: User) -> dict:
|
|
||||||
return {
|
|
||||||
"id": user.id,
|
|
||||||
"created_at": user.created_at.isoformat(),
|
|
||||||
"last_seen": user.last_seen.isoformat(),
|
|
||||||
"online": user.online,
|
|
||||||
"username": user.username,
|
|
||||||
"admin": user.username == OWNER_USERNAME
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.get("/check_auth")
|
|
||||||
def check_auth(current_user: User = Depends(get_current_user)):
|
|
||||||
return {
|
|
||||||
"authenticated": True,
|
|
||||||
"username": current_user.username,
|
|
||||||
"admin": current_user.username == OWNER_USERNAME
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
|
||||||
def login(request: LoginRequest, db: Session = Depends(get_db)):
|
|
||||||
user = db.query(User).filter(User.username == request.username.strip()).first()
|
|
||||||
|
|
||||||
if not user or not verify_password(request.password.strip(), user.password_hash):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="Неверное имя пользователя или пароль"
|
|
||||||
)
|
|
||||||
|
|
||||||
user.online = True
|
|
||||||
user.last_seen = datetime.now()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
token = create_token(user.id, user.username)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Login successful",
|
|
||||||
"token": token,
|
|
||||||
"user": convert_user(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register")
|
|
||||||
def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
|
||||||
username = request.username.strip()
|
|
||||||
password = request.password.strip()
|
|
||||||
confirm_password = request.confirm_password.strip()
|
|
||||||
|
|
||||||
# Determine if owner already exists
|
|
||||||
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
|
|
||||||
|
|
||||||
# If owner not yet registered, only allow the owner to register
|
|
||||||
if not owner_exists and username != OWNER_USERNAME:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Регистрация временно закрыта до регистрации владельца"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate input
|
|
||||||
if not is_valid_username(username):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Имя пользователя должно быть от 3 до 20 символов и не содержать пробелов"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not is_valid_password(password):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Пароль должен быть от 5 до 50 символов и не содержать пробелов"
|
|
||||||
)
|
|
||||||
|
|
||||||
if password != confirm_password:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Пароли не совпадают"
|
|
||||||
)
|
|
||||||
|
|
||||||
# After owner exists, disallow registering the reserved owner username via public registration
|
|
||||||
if owner_exists and username == OWNER_USERNAME:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Это имя пользователя зарезервировано"
|
|
||||||
)
|
|
||||||
|
|
||||||
existing_user = db.query(User).filter(User.username == username).first()
|
|
||||||
if existing_user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Это имя пользователя уже занято"
|
|
||||||
)
|
|
||||||
|
|
||||||
hashed_password = get_password_hash(password)
|
|
||||||
new_user = User(
|
|
||||||
username=username,
|
|
||||||
password_hash=hashed_password,
|
|
||||||
online=True,
|
|
||||||
last_seen=datetime.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(new_user)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(new_user)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Регистрация прошла успешно Теперь вы можете войти."
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/admin/user/{user_id}")
|
|
||||||
def delete_user_as_owner(
|
|
||||||
user_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
# Only owner can delete users
|
|
||||||
if current_user.username != OWNER_USERNAME:
|
|
||||||
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Prevent deleting the owner account via API
|
|
||||||
if user.username == OWNER_USERNAME:
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete owner account")
|
|
||||||
|
|
||||||
# Manually delete user's messages to satisfy FK constraints
|
|
||||||
from models import Message # local import to avoid circular
|
|
||||||
db.query(Message).filter(Message.user_id == user.id).delete()
|
|
||||||
|
|
||||||
db.delete(user)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"status": "success", "deleted_user_id": user_id}
|
|
||||||
|
|
||||||
@router.get("/logout")
|
|
||||||
def logout(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
current_user.online = False
|
|
||||||
current_user.last_seen = datetime.now()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Logged out successfully"
|
|
||||||
}
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
import logging
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from dependencies import get_current_user, get_db
|
|
||||||
from constants import OWNER_USERNAME
|
|
||||||
from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
|
||||||
|
|
||||||
def convert_message(msg: Message) -> dict:
|
|
||||||
return {
|
|
||||||
"id": msg.id,
|
|
||||||
"content": msg.content,
|
|
||||||
"timestamp": msg.timestamp.isoformat(),
|
|
||||||
"is_read": msg.is_read,
|
|
||||||
"is_edited": msg.is_edited,
|
|
||||||
"username": msg.author.username,
|
|
||||||
"profile_picture": msg.author.profile_picture,
|
|
||||||
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send_message")
|
|
||||||
async def send_message(
|
|
||||||
request: SendMessageRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
if not request.content.strip():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="No content provided"
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(request.content.strip()) > 4096:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Message too long"
|
|
||||||
)
|
|
||||||
|
|
||||||
new_message = Message(
|
|
||||||
content=request.content.strip(),
|
|
||||||
user_id=current_user.id,
|
|
||||||
timestamp=datetime.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(new_message)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(new_message)
|
|
||||||
|
|
||||||
return {"status": "success", "message": convert_message(new_message)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/get_messages")
|
|
||||||
async def get_messages(db: Session = Depends(get_db)):
|
|
||||||
messages = db.query(Message).order_by(Message.timestamp.asc()).all()
|
|
||||||
|
|
||||||
messages_data = []
|
|
||||||
for msg in messages:
|
|
||||||
messages_data.append(convert_message(msg))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"messages": messages_data
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/edit_message/{message_id}")
|
|
||||||
async def edit_message(
|
|
||||||
message_id: int,
|
|
||||||
request: EditMessageRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
message = db.query(Message).filter(Message.id == message_id).first()
|
|
||||||
|
|
||||||
if not message:
|
|
||||||
raise HTTPException(status_code=404, detail="Message not found")
|
|
||||||
|
|
||||||
if message.user_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="You can only edit your own messages")
|
|
||||||
|
|
||||||
if not request.content.strip():
|
|
||||||
raise HTTPException(status_code=400, detail="Message content cannot be empty")
|
|
||||||
|
|
||||||
message.content = request.content.strip()
|
|
||||||
message.is_edited = True
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(message)
|
|
||||||
|
|
||||||
return {"status": "success", "message": convert_message(message)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/delete_message/{message_id}")
|
|
||||||
async def delete_message(
|
|
||||||
message_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
message = db.query(Message).filter(Message.id == message_id).first()
|
|
||||||
|
|
||||||
if not message:
|
|
||||||
raise HTTPException(status_code=404, detail="Message not found")
|
|
||||||
|
|
||||||
# Allow owner to delete any message
|
|
||||||
if current_user.username != OWNER_USERNAME and message.user_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="You can only delete your own messages")
|
|
||||||
|
|
||||||
db.delete(message)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"status": "success", "message_id": message_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reply_message")
|
|
||||||
async def reply_message(
|
|
||||||
request: ReplyMessageRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
# Check if the message being replied to exists
|
|
||||||
original_message = db.query(Message).filter(Message.id == request.reply_to_id).first()
|
|
||||||
if not original_message:
|
|
||||||
raise HTTPException(status_code=404, detail="Original message not found")
|
|
||||||
|
|
||||||
if not request.content.strip():
|
|
||||||
raise HTTPException(status_code=400, detail="No content provided")
|
|
||||||
|
|
||||||
new_message = Message(
|
|
||||||
content=request.content.strip(),
|
|
||||||
user_id=current_user.id,
|
|
||||||
timestamp=datetime.now(),
|
|
||||||
reply_to_id=request.reply_to_id
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(new_message)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(new_message)
|
|
||||||
|
|
||||||
return {"status": "success", "message": convert_message(new_message)}
|
|
||||||
|
|
||||||
|
|
||||||
class MessaggingSocketManager:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.connections: list[WebSocket] = []
|
|
||||||
|
|
||||||
async def send_error(self, websocket: WebSocket, type: str, e: HTTPException):
|
|
||||||
await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}})
|
|
||||||
|
|
||||||
async def handle_connection(self, websocket: WebSocket, db: Session):
|
|
||||||
while True:
|
|
||||||
data = await websocket.receive_json()
|
|
||||||
type = data["type"]
|
|
||||||
|
|
||||||
def get_current_user_inner() -> User | None:
|
|
||||||
if data["credentials"]:
|
|
||||||
return get_current_user(
|
|
||||||
HTTPAuthorizationCredentials(
|
|
||||||
scheme=data["credentials"]["scheme"],
|
|
||||||
credentials=data["credentials"]["credentials"]
|
|
||||||
),
|
|
||||||
db
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if type == "ping":
|
|
||||||
await websocket.send_json({"type": "ping", "data": {"status": "success"}})
|
|
||||||
elif type == "getMessages":
|
|
||||||
try:
|
|
||||||
current_user = get_current_user_inner()
|
|
||||||
if not current_user:
|
|
||||||
raise HTTPException(401)
|
|
||||||
|
|
||||||
await websocket.send_json({"type": type, "data": await get_messages(current_user, db)})
|
|
||||||
except HTTPException as e:
|
|
||||||
await self.send_error(websocket, type, e)
|
|
||||||
elif type == "sendMessage":
|
|
||||||
try:
|
|
||||||
current_user = get_current_user_inner()
|
|
||||||
if not current_user:
|
|
||||||
raise HTTPException(401)
|
|
||||||
|
|
||||||
request: SendMessageRequest = SendMessageRequest.model_validate(data["data"])
|
|
||||||
|
|
||||||
response = await send_message(request, current_user, db)
|
|
||||||
await self.broadcast({
|
|
||||||
"type": "newMessage",
|
|
||||||
"data": response["message"]
|
|
||||||
})
|
|
||||||
|
|
||||||
await websocket.send_json({"type": type, "data": response})
|
|
||||||
except HTTPException as e:
|
|
||||||
await self.send_error(websocket, type, e)
|
|
||||||
elif type == "editMessage":
|
|
||||||
try:
|
|
||||||
current_user = get_current_user_inner()
|
|
||||||
if not current_user:
|
|
||||||
raise HTTPException(401)
|
|
||||||
|
|
||||||
message_id = data["data"]["message_id"]
|
|
||||||
request: EditMessageRequest = EditMessageRequest.model_validate(data["data"])
|
|
||||||
|
|
||||||
response = await edit_message(message_id, request, current_user, db)
|
|
||||||
await self.broadcast({
|
|
||||||
"type": "messageEdited",
|
|
||||||
"data": response["message"]
|
|
||||||
})
|
|
||||||
|
|
||||||
await websocket.send_json({"type": type, "data": response})
|
|
||||||
except HTTPException as e:
|
|
||||||
await self.send_error(websocket, type, e)
|
|
||||||
elif type == "deleteMessage":
|
|
||||||
try:
|
|
||||||
current_user = get_current_user_inner()
|
|
||||||
if not current_user:
|
|
||||||
raise HTTPException(401)
|
|
||||||
|
|
||||||
message_id = data["data"]["message_id"]
|
|
||||||
response = await delete_message(message_id, current_user, db)
|
|
||||||
await self.broadcast({
|
|
||||||
"type": "messageDeleted",
|
|
||||||
"data": {"message_id": message_id}
|
|
||||||
})
|
|
||||||
|
|
||||||
await websocket.send_json({"type": type, "data": response})
|
|
||||||
except HTTPException as e:
|
|
||||||
await self.send_error(websocket, type, e)
|
|
||||||
elif type == "replyMessage":
|
|
||||||
try:
|
|
||||||
current_user = get_current_user_inner()
|
|
||||||
if not current_user:
|
|
||||||
raise HTTPException(401)
|
|
||||||
|
|
||||||
request: ReplyMessageRequest = ReplyMessageRequest.model_validate(data["data"])
|
|
||||||
response = await reply_message(request, current_user, db)
|
|
||||||
await self.broadcast({
|
|
||||||
"type": "newMessage",
|
|
||||||
"data": response["message"]
|
|
||||||
})
|
|
||||||
|
|
||||||
await websocket.send_json({"type": type, "data": response})
|
|
||||||
except HTTPException as e:
|
|
||||||
await self.send_error(websocket, type, e)
|
|
||||||
else:
|
|
||||||
await websocket.send_json({"type": type, "error": {"code": 400, "detail": "Invalid type"}})
|
|
||||||
|
|
||||||
async def disconnect(self, websocket: WebSocket, code: int = 1000, message: str | None = None):
|
|
||||||
try:
|
|
||||||
await websocket.close(code=code, reason=message)
|
|
||||||
finally:
|
|
||||||
self.connections.remove(websocket)
|
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket, db: Session):
|
|
||||||
await websocket.accept()
|
|
||||||
self.connections.append(websocket)
|
|
||||||
try:
|
|
||||||
await self.handle_connection(websocket, db)
|
|
||||||
except WebSocketDisconnect as e:
|
|
||||||
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}")
|
|
||||||
finally:
|
|
||||||
self.connections.remove(websocket)
|
|
||||||
|
|
||||||
async def broadcast(self, message: dict):
|
|
||||||
for websocket in self.connections:
|
|
||||||
await websocket.send_json(message)
|
|
||||||
|
|
||||||
messagingManager = MessaggingSocketManager()
|
|
||||||
|
|
||||||
@router.websocket("/chat/ws")
|
|
||||||
async def chat_websocket(
|
|
||||||
websocket: WebSocket,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
await messagingManager.connect(websocket, db)
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from PIL import Image
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
import io
|
|
||||||
|
|
||||||
from dependencies import get_db, get_current_user
|
|
||||||
from models import User, UpdateBioRequest, UserProfileResponse
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
# Create uploads directory if it doesn't exist
|
|
||||||
PROFILE_PICTURES_DIR = Path("data/uploads/pfp")
|
|
||||||
|
|
||||||
os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
@router.post("/upload-profile-picture")
|
|
||||||
async def upload_profile_picture(
|
|
||||||
profile_picture: UploadFile = File(...),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Upload and process a profile picture
|
|
||||||
"""
|
|
||||||
# Validate file type
|
|
||||||
if not profile_picture.content_type.startswith('image/'):
|
|
||||||
raise HTTPException(status_code=400, detail="File must be an image")
|
|
||||||
|
|
||||||
# Validate file size (max 5MB)
|
|
||||||
if profile_picture.size > 5 * 1024 * 1024:
|
|
||||||
raise HTTPException(status_code=400, detail="File size must be less than 5MB")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Read and process the image
|
|
||||||
image_data = await profile_picture.read()
|
|
||||||
|
|
||||||
# Open image with PIL
|
|
||||||
image = Image.open(io.BytesIO(image_data))
|
|
||||||
|
|
||||||
# Convert to RGB if necessary
|
|
||||||
if image.mode != 'RGB':
|
|
||||||
image = image.convert('RGB')
|
|
||||||
|
|
||||||
# Resize to a reasonable size (200x200)
|
|
||||||
image.thumbnail((200, 200), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
# Generate unique filename
|
|
||||||
filename = f"{current_user.id}_{uuid.uuid4().hex}.jpg"
|
|
||||||
filepath = os.path.join(PROFILE_PICTURES_DIR, filename)
|
|
||||||
|
|
||||||
# Save the processed image
|
|
||||||
image.save(filepath, 'JPEG', quality=85)
|
|
||||||
|
|
||||||
# Update user's profile picture in database
|
|
||||||
profile_picture_url = f"/api/profile-picture/{filename}"
|
|
||||||
current_user.profile_picture = profile_picture_url
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Profile picture uploaded successfully",
|
|
||||||
"profile_picture_url": profile_picture_url
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)}")
|
|
||||||
|
|
||||||
@router.get("/profile-picture/{filename}")
|
|
||||||
async def get_profile_picture(filename: str):
|
|
||||||
"""
|
|
||||||
Serve profile picture files
|
|
||||||
"""
|
|
||||||
filepath = os.path.join(PROFILE_PICTURES_DIR, filename)
|
|
||||||
|
|
||||||
if not os.path.exists(filepath):
|
|
||||||
raise HTTPException(status_code=404, detail="Profile picture not found")
|
|
||||||
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
return FileResponse(filepath, media_type="image/jpeg")
|
|
||||||
|
|
||||||
@router.get("/user/profile")
|
|
||||||
async def get_user_profile(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get current user's profile information
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"id": current_user.id,
|
|
||||||
"username": current_user.username,
|
|
||||||
"profile_picture": current_user.profile_picture,
|
|
||||||
"bio": current_user.bio,
|
|
||||||
"online": current_user.online,
|
|
||||||
"last_seen": current_user.last_seen,
|
|
||||||
"created_at": current_user.created_at
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/user/bio")
|
|
||||||
async def update_user_bio(
|
|
||||||
request: UpdateBioRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Update current user's bio
|
|
||||||
"""
|
|
||||||
if len(request.bio) > 500: # Limit bio to 500 characters
|
|
||||||
raise HTTPException(status_code=400, detail="Bio must be 500 characters or less")
|
|
||||||
|
|
||||||
current_user.bio = request.bio.strip()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Bio updated successfully",
|
|
||||||
"bio": current_user.bio
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/user/{username}")
|
|
||||||
async def get_user_by_username(
|
|
||||||
username: str,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get user profile by username
|
|
||||||
"""
|
|
||||||
user = db.query(User).filter(User.username == username).first()
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
return UserProfileResponse(
|
|
||||||
id=user.id,
|
|
||||||
username=user.username,
|
|
||||||
profile_picture=user.profile_picture,
|
|
||||||
bio=user.bio,
|
|
||||||
online=user.online,
|
|
||||||
last_seen=user.last_seen,
|
|
||||||
created_at=user.created_at
|
|
||||||
)
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from datetime import datetime, timedelta
|
|
||||||
import jwt
|
|
||||||
from typing import Optional
|
|
||||||
import bcrypt
|
|
||||||
|
|
||||||
from constants import *
|
|
||||||
|
|
||||||
# JWT Helper Functions
|
|
||||||
def create_token(user_id: int, username: str) -> str:
|
|
||||||
expire = datetime.now() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
|
|
||||||
payload = {
|
|
||||||
"user_id": user_id,
|
|
||||||
"username": username,
|
|
||||||
"exp": expire
|
|
||||||
}
|
|
||||||
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_token(token: str) -> Optional[dict]:
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
|
||||||
return payload
|
|
||||||
except jwt.ExpiredSignatureError:
|
|
||||||
return None
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
||||||
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
|
||||||
|
|
||||||
def get_password_hash(password: str) -> str:
|
|
||||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
def is_valid_username(username: str) -> bool:
|
|
||||||
if len(username) < 3 or len(username) > 20:
|
|
||||||
return False
|
|
||||||
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', username):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_password(password: str) -> bool:
|
|
||||||
if len(password) < 5 or len(password) > 50:
|
|
||||||
return False
|
|
||||||
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', password):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://api.fromchat.ru}
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "8301:80"
|
||||||
|
restart: always
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Node.js
|
|
||||||
node_modules
|
|
||||||
npm-debug.log
|
|
||||||
.env
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
|
|
||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.pytest_cache/
|
|
||||||
.mypy_cache/
|
|
||||||
.ipynb_checkpoints
|
|
||||||
.venv
|
|
||||||
venv/
|
|
||||||
|
|
||||||
# Git
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# Common
|
|
||||||
# Exclude editor and OS files, as well as test results
|
|
||||||
dist
|
|
||||||
dist-electron
|
|
||||||
build
|
|
||||||
coverage
|
|
||||||
test_results/
|
|
||||||
out
|
|
||||||
|
|
||||||
data
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
JWT_SECRET="jwt-secret-change-in-production"
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# 1. Install pip dependencies
|
|
||||||
FROM python:3.12 AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
RUN python3 -m venv .venv
|
|
||||||
COPY backend/requirements.txt .
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
|
||||||
./.venv/bin/pip3 install -r requirements.txt
|
|
||||||
|
|
||||||
# 2. Runtime stage
|
|
||||||
FROM python:3.12-slim AS runtime
|
|
||||||
|
|
||||||
# 2.1. Non-root user
|
|
||||||
WORKDIR /app
|
|
||||||
RUN useradd -u 1000 app && \
|
|
||||||
chown -R app /app
|
|
||||||
USER app
|
|
||||||
|
|
||||||
# 2.2. Copy content and create dirs
|
|
||||||
COPY --chown=app backend .
|
|
||||||
COPY --from=builder --chown=app /app/.venv .venv
|
|
||||||
RUN mkdir -p /app/data
|
|
||||||
|
|
||||||
# 3. Final command
|
|
||||||
ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
services:
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
dockerfile: deployment/Dockerfile.backend
|
|
||||||
context: ..
|
|
||||||
environment:
|
|
||||||
PORT: 8300
|
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
|
||||||
volumes:
|
|
||||||
- "data:/app/data"
|
|
||||||
develop:
|
|
||||||
watch:
|
|
||||||
- action: sync+restart
|
|
||||||
path: ../backend
|
|
||||||
target: /app
|
|
||||||
- action: rebuild
|
|
||||||
path: ../backend/requirements.txt
|
|
||||||
networks:
|
|
||||||
- main
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
dockerfile: deployment/frontend/Dockerfile
|
|
||||||
context: ..
|
|
||||||
environment:
|
|
||||||
PORT: 8301
|
|
||||||
BACKEND_HOST: http://backend:8300
|
|
||||||
ports:
|
|
||||||
- "8301:8301"
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
develop:
|
|
||||||
watch:
|
|
||||||
- action: rebuild
|
|
||||||
path: ../frontend
|
|
||||||
- action: sync+restart
|
|
||||||
path: server.js
|
|
||||||
target: /server/server.js
|
|
||||||
- action: rebuild
|
|
||||||
path: package.json
|
|
||||||
networks:
|
|
||||||
- main
|
|
||||||
- default
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
data:
|
|
||||||
name: fromchat-data
|
|
||||||
|
|
||||||
networks:
|
|
||||||
main:
|
|
||||||
driver: bridge
|
|
||||||
internal: true # isolate from the outside world
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=FromChat server
|
|
||||||
After=multi-user.target
|
|
||||||
Wants=network-online.target
|
|
||||||
After=network-online.target
|
|
||||||
StartLimitIntervalSec=60
|
|
||||||
StartLimitBurst=3
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
Group=root
|
|
||||||
ExecStart=/bin/docker compose up
|
|
||||||
ExecStop=/bin/docker compose down
|
|
||||||
WorkingDirectory=/home/denis0001-dev/actions-runner/_work/FromChat/FromChat/deployment
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
# Security settings
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ReadWritePaths=/var/log
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
StandardInput=tty-force
|
|
||||||
SyslogIdentifier=fromchat
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# 1. Build the frontend
|
|
||||||
FROM node:24 AS frontend
|
|
||||||
|
|
||||||
# 1.1. Install npm dependencies
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json .
|
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
|
||||||
npm install --ignore-scripts
|
|
||||||
|
|
||||||
# 1.2. Build
|
|
||||||
COPY frontend frontend
|
|
||||||
RUN npm run frontend:build
|
|
||||||
|
|
||||||
# 2. Build the static file server
|
|
||||||
FROM node:24 AS server
|
|
||||||
|
|
||||||
# 2.1. Install npm dependencies
|
|
||||||
WORKDIR /server
|
|
||||||
COPY deployment/frontend/package.json .
|
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 2.2. Copy the code
|
|
||||||
COPY deployment/frontend/ .
|
|
||||||
|
|
||||||
# 3. Put it all together
|
|
||||||
FROM node:24-slim
|
|
||||||
|
|
||||||
# 3.1. Non-root user
|
|
||||||
RUN useradd -u 1001 app && \
|
|
||||||
mkdir -p /app && \
|
|
||||||
chown -R app /app && \
|
|
||||||
mkdir /server && \
|
|
||||||
chown -R app /server
|
|
||||||
USER app
|
|
||||||
|
|
||||||
# 3.1. Frontend static files
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=frontend --chown=app /app/frontend/dist .
|
|
||||||
|
|
||||||
# 3.2. Static file server
|
|
||||||
WORKDIR /server
|
|
||||||
COPY --from=server --chown=app /server .
|
|
||||||
|
|
||||||
# 4. Final command
|
|
||||||
ENV STATIC_FILE_PATH=/app
|
|
||||||
ENTRYPOINT ["npm", "run", "start"]
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "frontend-server",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "server.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node server.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^5.1.0",
|
|
||||||
"http-proxy-middleware": "^3.0.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// server.js
|
|
||||||
const express = require('express');
|
|
||||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const port = process.env.PORT || 3000;
|
|
||||||
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
|
|
||||||
const file_path = process.env.STATIC_FILE_PATH || ".";
|
|
||||||
|
|
||||||
app.use('/api', createProxyMiddleware({
|
|
||||||
target: backendHost,
|
|
||||||
changeOrigin: true,
|
|
||||||
pathRewrite: { '^/api': '' },
|
|
||||||
ws: true
|
|
||||||
}));
|
|
||||||
|
|
||||||
app.use(express.static(path.resolve(file_path)));
|
|
||||||
|
|
||||||
app.listen(port, () => {
|
|
||||||
console.log(`Server launched на http://localhost:${port}`);
|
|
||||||
});
|
|
||||||
Vendored
-12
@@ -1,12 +0,0 @@
|
|||||||
export type Platform = "win32" | "darwin" | "linux"
|
|
||||||
|
|
||||||
export interface ElectronInterface {
|
|
||||||
desktop: true,
|
|
||||||
platform: Platform
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
electronInterface: ElectronInterface
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
|
||||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
|
||||||
import type { ForgeConfig } from "@electron-forge/shared-types";
|
|
||||||
|
|
||||||
const config: ForgeConfig = {
|
|
||||||
packagerConfig: {
|
|
||||||
asar: true
|
|
||||||
},
|
|
||||||
rebuildConfig: {},
|
|
||||||
makers: [
|
|
||||||
{
|
|
||||||
name: '@electron-forge/maker-squirrel',
|
|
||||||
config: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '@electron-forge/maker-zip',
|
|
||||||
config: {},
|
|
||||||
platforms: ['darwin'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '@electron-forge/maker-deb',
|
|
||||||
config: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '@electron-forge/maker-rpm',
|
|
||||||
config: {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
name: '@electron-forge/plugin-auto-unpack-natives',
|
|
||||||
config: {},
|
|
||||||
},
|
|
||||||
// Fuses are used to enable/disable various Electron functionality
|
|
||||||
// at package time, before code signing the application
|
|
||||||
new FusesPlugin({
|
|
||||||
version: FuseVersion.V1,
|
|
||||||
[FuseV1Options.RunAsNode]: false,
|
|
||||||
[FuseV1Options.EnableCookieEncryption]: true,
|
|
||||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
|
||||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
|
||||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
|
||||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<title>Hello World!</title>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>💖 Hello World!</h1>
|
|
||||||
<p>Welcome to your Electron application.</p>
|
|
||||||
<script type="module" src="/src/renderer.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "FromChat",
|
|
||||||
"productName": "FromChat",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "A 100% Open Source Messenger",
|
|
||||||
"main": "dist-electron/main.js",
|
|
||||||
"scripts": {
|
|
||||||
"package": "electron-forge package",
|
|
||||||
"make": "electron-forge make",
|
|
||||||
"publish": "electron-forge publish"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": {
|
|
||||||
"name": "denis0001-dev",
|
|
||||||
"email": "denis0001.dev@ya.ru"
|
|
||||||
},
|
|
||||||
"license": "GPL-2.0",
|
|
||||||
"devDependencies": {
|
|
||||||
"@electron-forge/cli": "^7.8.3",
|
|
||||||
"@electron-forge/maker-deb": "^7.8.3",
|
|
||||||
"@electron-forge/maker-rpm": "^7.8.3",
|
|
||||||
"@electron-forge/maker-squirrel": "^7.8.3",
|
|
||||||
"@electron-forge/maker-zip": "^7.8.3",
|
|
||||||
"@electron-forge/plugin-auto-unpack-natives": "^7.8.3",
|
|
||||||
"@electron-forge/plugin-fuses": "^7.8.3",
|
|
||||||
"@electron/fuses": "^1.8.0",
|
|
||||||
"electron": "37.3.1",
|
|
||||||
"vite": "^5.4.19"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"electron-squirrel-startup": "^1.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { app, BrowserWindow } from 'electron';
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
|
||||||
const win = new BrowserWindow({
|
|
||||||
title: 'Main window',
|
|
||||||
minWidth: 650,
|
|
||||||
minHeight: 420,
|
|
||||||
webPreferences: {
|
|
||||||
preload: path.join(import.meta.dirname, "preload.mjs")
|
|
||||||
},
|
|
||||||
titleBarStyle: "hidden",
|
|
||||||
...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
|
|
||||||
trafficLightPosition: {
|
|
||||||
x: 16 - 4,
|
|
||||||
y: 16 - 4
|
|
||||||
},
|
|
||||||
titleBarOverlay: process.platform !== "darwin"
|
|
||||||
})
|
|
||||||
|
|
||||||
// You can use `process.env.VITE_DEV_SERVER_URL` when the vite command is called `serve`
|
|
||||||
if (process.env.VITE_DEV_SERVER_URL) {
|
|
||||||
win.loadURL(process.env.VITE_DEV_SERVER_URL)
|
|
||||||
} else {
|
|
||||||
// Load your file
|
|
||||||
win.loadFile('dist/index.html');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { contextBridge } from "electron";
|
|
||||||
import type { ElectronInterface, Platform } from "../electron";
|
|
||||||
|
|
||||||
const electronInterface: ElectronInterface = {
|
|
||||||
desktop: true,
|
|
||||||
platform: process.platform as Platform
|
|
||||||
}
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronInterface", electronInterface);
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru" class="mdui-theme-dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Loading...</title>
|
|
||||||
<link rel="icon" href="./src/images/logo.png" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="electron-title-bar">
|
|
||||||
<div class="macos-padding"></div>
|
|
||||||
<div id="window-title"></div>
|
|
||||||
<!-- <div class="window-controls">
|
|
||||||
<mdui-button-icon icon="remove" id="window-minimize"></mdui-button-icon>
|
|
||||||
<mdui-button-icon icon="stack--outlined" id="window-restore" class="hidden"></mdui-button-icon>
|
|
||||||
<mdui-button-icon icon="ad--outlined" id="window-maximize"></mdui-button-icon>
|
|
||||||
<mdui-button-icon icon="close" id="window-close"></mdui-button-icon>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
|
||||||
<div id="main-wrapper">
|
|
||||||
<!-- Login Form -->
|
|
||||||
<div id="login-form" class="auth-container">
|
|
||||||
<div class="auth-card fade-in">
|
|
||||||
<div class="auth-header">
|
|
||||||
<h2>
|
|
||||||
<span class="material-symbols filled large">login</span>
|
|
||||||
Добро пожаловать!
|
|
||||||
</h2>
|
|
||||||
<p>Войдите в свой аккаунт</p>
|
|
||||||
</div>
|
|
||||||
<div class="auth-body">
|
|
||||||
<div id="login-alerts"></div>
|
|
||||||
|
|
||||||
<form id="login-form-element">
|
|
||||||
<mdui-text-field
|
|
||||||
label="Имя пользователя"
|
|
||||||
id="login-username"
|
|
||||||
name="username"
|
|
||||||
variant="outlined"
|
|
||||||
icon="person--filled"
|
|
||||||
autocomplete="username"
|
|
||||||
required>
|
|
||||||
</mdui-text-field>
|
|
||||||
<mdui-text-field
|
|
||||||
label="Пароль"
|
|
||||||
id="login-password"
|
|
||||||
name="password"
|
|
||||||
variant="outlined"
|
|
||||||
type="password"
|
|
||||||
toggle-password
|
|
||||||
icon="password--filled"
|
|
||||||
autocomplete="current-password"
|
|
||||||
required>
|
|
||||||
</mdui-text-field>
|
|
||||||
|
|
||||||
<mdui-button type="submit">Войти</mdui-button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<p>Ещё нет аккаунта? <a href="#" id="register-link" class="link">Зарегистрируйтесь</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Register Form -->
|
|
||||||
<div id="register-form" class="auth-container" style="display: none;">
|
|
||||||
<div class="auth-card fade-in">
|
|
||||||
<div class="auth-header">
|
|
||||||
<h2>
|
|
||||||
<span class="material-symbols filled large">person_add</span>
|
|
||||||
Регистрация
|
|
||||||
</h2>
|
|
||||||
<p>Создайте новый аккаунт</p>
|
|
||||||
</div>
|
|
||||||
<div class="auth-body">
|
|
||||||
<div id="register-alerts"></div>
|
|
||||||
|
|
||||||
<form id="register-form-element">
|
|
||||||
<mdui-text-field
|
|
||||||
label="Имя пользователя"
|
|
||||||
id="register-username"
|
|
||||||
name="username"
|
|
||||||
variant="outlined"
|
|
||||||
icon="person--filled"
|
|
||||||
autocomplete="username"
|
|
||||||
maxlength="20"
|
|
||||||
counter
|
|
||||||
required>
|
|
||||||
</mdui-text-field>
|
|
||||||
<mdui-text-field
|
|
||||||
label="Пароль"
|
|
||||||
id="register-password"
|
|
||||||
name="password"
|
|
||||||
variant="outlined"
|
|
||||||
type="password"
|
|
||||||
toggle-password
|
|
||||||
icon="password--filled"
|
|
||||||
autocomplete="new-password"
|
|
||||||
required>
|
|
||||||
</mdui-text-field>
|
|
||||||
<mdui-text-field
|
|
||||||
label="Подтвердите пароль"
|
|
||||||
id="register-confirm-password"
|
|
||||||
name="confirm_password"
|
|
||||||
variant="outlined"
|
|
||||||
type="password"
|
|
||||||
toggle-password
|
|
||||||
icon="password--filled"
|
|
||||||
autocomplete="new-password"
|
|
||||||
required>
|
|
||||||
</mdui-text-field>
|
|
||||||
|
|
||||||
<mdui-button type="submit">Зарегистрироваться</mdui-button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<p>
|
|
||||||
Уже есть аккаунт?
|
|
||||||
<a href="#" id="login-link" class="link">Войдите</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chat Interface -->
|
|
||||||
<div id="chat-interface" style="display: none;">
|
|
||||||
<div class="all-container">
|
|
||||||
<div class="chat-list" id="chat-list">
|
|
||||||
<header class="chat-header-left">
|
|
||||||
<div id="productname">Loading...</div>
|
|
||||||
<div class="profile">
|
|
||||||
<a href="#" id="profile-open">
|
|
||||||
<img src="./src/images/default-avatar.png" alt="" id="preview1" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class="chat-tabs">
|
|
||||||
<mdui-tabs value="chats" full-width>
|
|
||||||
<mdui-tab value="chats">
|
|
||||||
Чаты
|
|
||||||
</mdui-tab>
|
|
||||||
<mdui-tab value="channels">
|
|
||||||
Каналы
|
|
||||||
</mdui-tab>
|
|
||||||
<mdui-tab value="contacts">
|
|
||||||
Контакты
|
|
||||||
</mdui-tab>
|
|
||||||
|
|
||||||
<mdui-tab-panel slot="panel" value="chats">
|
|
||||||
<mdui-list>
|
|
||||||
<mdui-list-item headline="Общий чат" description="Вы: Последнее сообщение" id="chat-list-chat-1">
|
|
||||||
<img src="./src/images/default-avatar.png" alt="" slot="icon" />
|
|
||||||
</mdui-list-item>
|
|
||||||
<mdui-list-item headline="Общий чат 2" description="Вы: Последнее сообщение" id="chat-list-chat-2">
|
|
||||||
<img src="./src/images/default-avatar.png" alt="" slot="icon" />
|
|
||||||
</mdui-list-item>
|
|
||||||
</mdui-list>
|
|
||||||
</mdui-tab-panel>
|
|
||||||
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
|
|
||||||
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
|
|
||||||
</mdui-tabs>
|
|
||||||
</div>
|
|
||||||
<mdui-bottom-app-bar>
|
|
||||||
<mdui-button-icon icon="settings--filled" id="settings-open"></mdui-button-icon>
|
|
||||||
<mdui-button-icon icon="group_add--filled"></mdui-button-icon>
|
|
||||||
<div style="flex-grow: 1"></div>
|
|
||||||
<mdui-fab icon="edit--filled"></mdui-fab>
|
|
||||||
</mdui-bottom-app-bar>
|
|
||||||
</div>
|
|
||||||
<div class="chat-container">
|
|
||||||
<div class="chat-main" id="chat-inner">
|
|
||||||
<div class="chat-header">
|
|
||||||
<img src="src/images/default-avatar.png" alt="Avatar" class="chat-header-avatar">
|
|
||||||
<div class="chat-header-info">
|
|
||||||
<div class="info-chat">
|
|
||||||
<h4 id="chat-name">Общий чат</h4>
|
|
||||||
<p>
|
|
||||||
<span class="online-status"></span>
|
|
||||||
Онлайн
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<a href="#" id="hide-chat">Свернуть чат</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chat-messages" id="chat-messages">
|
|
||||||
<!-- Messages will be loaded here dynamically -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chat-input-wrapper">
|
|
||||||
<div class="chat-input">
|
|
||||||
<form class="input-group" id="message-form">
|
|
||||||
<input type="text" class="message-input" id="message-input" placeholder="Напишите сообщение..." autocomplete="off">
|
|
||||||
<button type="submit" class="send-btn">
|
|
||||||
<span class="material-symbols filled">send</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<mdui-dialog id="profile-dialog" close-on-overlay-click close-on-esc>
|
|
||||||
<div class="content">
|
|
||||||
<div class="header-top">
|
|
||||||
<div class="profile-picture-container">
|
|
||||||
<img id="profile-picture" src="./src/images/default-avatar.png" alt="Ваше фото" />
|
|
||||||
<mdui-button-icon icon="camera_alt--filled" id="upload-pfp-btn" class="upload-overlay" variant="filled"></mdui-button-icon>
|
|
||||||
<input type="file" id="pfp-file-input" accept="image/*" style="display: none;">
|
|
||||||
</div>
|
|
||||||
<mdui-text-field id="username-field" label="Имя пользователя" variant="outlined" value="user123" autocomplete="username"></mdui-text-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="profile-form">
|
|
||||||
<mdui-text-field
|
|
||||||
id="description-field"
|
|
||||||
label="О себе"
|
|
||||||
variant="outlined"
|
|
||||||
multiline
|
|
||||||
rows="3"
|
|
||||||
placeholder="Расскажите о себе..."
|
|
||||||
autocomplete="none"></mdui-text-field>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<mdui-button type="submit" id="profile-submit">Сохранить изменения</mdui-button>
|
|
||||||
<mdui-button id="profile-dialog-close" variant="outlined">Закрыть</mdui-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mdui-dialog>
|
|
||||||
|
|
||||||
<!-- Profile Picture Cropper Dialog -->
|
|
||||||
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
|
|
||||||
<div class="cropper-dialog-content">
|
|
||||||
<div class="cropper-header">
|
|
||||||
<h3>Обрезать фото профиля</h3>
|
|
||||||
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon>
|
|
||||||
</div>
|
|
||||||
<div class="cropper-container">
|
|
||||||
<div id="cropper-area"></div>
|
|
||||||
</div>
|
|
||||||
<div class="cropper-actions">
|
|
||||||
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button>
|
|
||||||
<mdui-button id="crop-save">Сохранить</mdui-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mdui-dialog>
|
|
||||||
|
|
||||||
<mdui-dialog id="settings-dialog" close-on-overlay-click close-on-esc fullscreen>
|
|
||||||
<div class="fullscreen-wrapper">
|
|
||||||
<div id="settings-dialog-inner">
|
|
||||||
<div class="header">
|
|
||||||
<mdui-button-icon icon="close" id="settings-close"></mdui-button-icon>
|
|
||||||
<mdui-top-app-bar-title>Настройки</mdui-top-app-bar-title>
|
|
||||||
</div>
|
|
||||||
<div id="settings-menu">
|
|
||||||
<mdui-list>
|
|
||||||
<mdui-list-item icon="notifications--filled" rounded active>Уведомления</mdui-list-item>
|
|
||||||
<mdui-list-item icon="palette--filled" rounded>Внешний вид</mdui-list-item>
|
|
||||||
<mdui-list-item icon="security--filled" rounded>Безопасность</mdui-list-item>
|
|
||||||
<mdui-list-item icon="language--filled" rounded>Язык</mdui-list-item>
|
|
||||||
<mdui-list-item icon="storage--filled" rounded>Хранилище</mdui-list-item>
|
|
||||||
<mdui-list-item icon="help--filled" rounded>Помощь</mdui-list-item>
|
|
||||||
<mdui-list-item icon="info--filled" rounded>О приложении</mdui-list-item>
|
|
||||||
</mdui-list>
|
|
||||||
<div class="screen">
|
|
||||||
<div id="notifications-settings" class="settings-panel active">
|
|
||||||
<h3>Уведомления</h3>
|
|
||||||
<mdui-switch checked>Новые сообщения</mdui-switch>
|
|
||||||
<mdui-switch checked>Звуковые уведомления</mdui-switch>
|
|
||||||
<mdui-switch>Уведомления о статусе</mdui-switch>
|
|
||||||
<mdui-switch checked>Email уведомления</mdui-switch>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="appearance-settings" class="settings-panel">
|
|
||||||
<h3>Внешний вид</h3>
|
|
||||||
<mdui-select label="Тема" variant="outlined">
|
|
||||||
<mdui-menu-item value="dark">Тёмная</mdui-menu-item>
|
|
||||||
<mdui-menu-item value="light">Светлая</mdui-menu-item>
|
|
||||||
<mdui-menu-item value="auto">Авто</mdui-menu-item>
|
|
||||||
</mdui-select>
|
|
||||||
<mdui-select label="Размер шрифта" variant="outlined">
|
|
||||||
<mdui-menu-item value="small">Маленький</mdui-menu-item>
|
|
||||||
<mdui-menu-item value="medium">Средний</mdui-menu-item>
|
|
||||||
<mdui-menu-item value="large">Большой</mdui-menu-item>
|
|
||||||
</mdui-select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="security-settings" class="settings-panel">
|
|
||||||
<h3>Безопасность</h3>
|
|
||||||
<mdui-button variant="outlined">Изменить пароль</mdui-button>
|
|
||||||
<mdui-button variant="outlined">Двухфакторная аутентификация</mdui-button>
|
|
||||||
<mdui-switch>Автоматический выход</mdui-switch>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="language-settings" class="settings-panel">
|
|
||||||
<h3>Язык</h3>
|
|
||||||
<mdui-select label="Выберите язык" variant="outlined">
|
|
||||||
<mdui-menu-item value="ru">Русский</mdui-menu-item>
|
|
||||||
<mdui-menu-item value="en">English</mdui-menu-item>
|
|
||||||
<mdui-menu-item value="es">Español</mdui-menu-item>
|
|
||||||
</mdui-select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="storage-settings" class="settings-panel">
|
|
||||||
<h3>Хранилище</h3>
|
|
||||||
<p>Использовано: 2.5 ГБ из 10 ГБ</p>
|
|
||||||
<mdui-linear-progress value="25"></mdui-linear-progress>
|
|
||||||
<mdui-button variant="outlined">Очистить кэш</mdui-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="help-settings" class="settings-panel">
|
|
||||||
<h3>Помощь</h3>
|
|
||||||
<mdui-button variant="outlined">Руководство пользователя</mdui-button>
|
|
||||||
<mdui-button variant="outlined">Связаться с поддержкой</mdui-button>
|
|
||||||
<mdui-button variant="outlined">FAQ</mdui-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="about-settings" class="settings-panel">
|
|
||||||
<h3>О приложении</h3>
|
|
||||||
<p>Версия: 1.0.0</p>
|
|
||||||
<p>© 2024 From Chat. Все права защищены.</p>
|
|
||||||
<mdui-button variant="outlined">Политика конфиденциальности</mdui-button>
|
|
||||||
<mdui-button variant="outlined">Условия использования</mdui-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mdui-dialog>
|
|
||||||
|
|
||||||
<div id="message-context-menu" class="context-menu">
|
|
||||||
<div class="context-menu-item" data-action="reply">
|
|
||||||
<span class="material-symbols">reply</span>
|
|
||||||
Reply
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" data-action="edit">
|
|
||||||
<span class="material-symbols">edit</span>
|
|
||||||
Edit
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" data-action="delete">
|
|
||||||
<span class="material-symbols">delete</span>
|
|
||||||
Delete
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<mdui-dialog id="edit-message-dialog" close-on-overlay-click close-on-esc>
|
|
||||||
<div class="dialog-content">
|
|
||||||
<h3>Edit Message</h3>
|
|
||||||
<mdui-text-field
|
|
||||||
id="edit-message-input"
|
|
||||||
label="Edit Message"
|
|
||||||
variant="outlined"
|
|
||||||
multiline
|
|
||||||
rows="4"
|
|
||||||
placeholder="Edit your message..."
|
|
||||||
maxlength="1000">
|
|
||||||
</mdui-text-field>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<mdui-button id="edit-cancel" variant="outlined">Cancel</mdui-button>
|
|
||||||
<mdui-button id="edit-save">Save</mdui-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mdui-dialog>
|
|
||||||
|
|
||||||
<mdui-dialog id="reply-message-dialog" close-on-overlay-click close-on-esc>
|
|
||||||
<div class="dialog-content">
|
|
||||||
<h3>Reply to Message</h3>
|
|
||||||
<div class="reply-preview" id="reply-preview"></div>
|
|
||||||
<mdui-text-field
|
|
||||||
id="reply-message-input"
|
|
||||||
label="Reply"
|
|
||||||
variant="outlined"
|
|
||||||
multiline
|
|
||||||
rows="4"
|
|
||||||
placeholder="Type your reply..."
|
|
||||||
maxlength="1000">
|
|
||||||
</mdui-text-field>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<mdui-button id="reply-cancel" variant="outlined">Cancel</mdui-button>
|
|
||||||
<mdui-button id="reply-send">Send Reply</mdui-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mdui-dialog>
|
|
||||||
|
|
||||||
<mdui-dialog id="user-profile-dialog" close-on-overlay-click close-on-esc>
|
|
||||||
<div class="content">
|
|
||||||
<div class="profile-picture-section">
|
|
||||||
<img class="profile-picture" src="" alt="Profile Picture">
|
|
||||||
</div>
|
|
||||||
<div class="profile-info">
|
|
||||||
<div class="username-section">
|
|
||||||
<h4 class="username"></h4>
|
|
||||||
<div class="online-status"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bio-section">
|
|
||||||
<label>Bio:</label>
|
|
||||||
<div class="bio-display"></div>
|
|
||||||
</div>
|
|
||||||
<div class="profile-stats">
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">Member since:</span>
|
|
||||||
<span class="stat-value member-since"></span>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">Last seen:</span>
|
|
||||||
<span class="stat-value last-seen"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mdui-dialog>
|
|
||||||
|
|
||||||
<script src="src/main.ts" type="module"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Authentication system implementation
|
|
||||||
* @description Handles user authentication, registration, and session management
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { loadMessages } from "./chat";
|
|
||||||
import { initializeProfile } from "./profile";
|
|
||||||
import type { Headers, ErrorResponse, User, LoginResponse, LoginRequest, RegisterRequest } from "./types";
|
|
||||||
import { API_BASE_URL } from "./config";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current authenticated user information
|
|
||||||
* @type {User | null}
|
|
||||||
*/
|
|
||||||
export let currentUser: User | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JWT authentication token
|
|
||||||
* @type {string | null}
|
|
||||||
*/
|
|
||||||
export let authToken: string | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates authentication headers for API requests
|
|
||||||
* @param {boolean} json - Whether to include JSON content type header
|
|
||||||
* @returns {Headers} Headers object with authentication and content type
|
|
||||||
* @function getAuthHeaders
|
|
||||||
* @example
|
|
||||||
* const headers = getAuthHeaders();
|
|
||||||
* fetch('/api/endpoint', { headers });
|
|
||||||
*/
|
|
||||||
export function getAuthHeaders(json: boolean = true): Headers {
|
|
||||||
const headers: Headers = {};
|
|
||||||
|
|
||||||
if (json) {
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authToken) {
|
|
||||||
headers['Authorization'] = `Bearer ${authToken}`;
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the login form and hides other interfaces
|
|
||||||
* @function showLogin
|
|
||||||
* @example
|
|
||||||
* showLogin();
|
|
||||||
*/
|
|
||||||
export function showLogin(): void {
|
|
||||||
document.getElementById('login-form')!.style.display = 'flex';
|
|
||||||
document.getElementById('register-form')!.style.display = 'none';
|
|
||||||
document.getElementById('chat-interface')!.style.display = 'none';
|
|
||||||
clearAlerts();
|
|
||||||
document.getElementById("electron-title-bar")!.classList.add("color-surface");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the registration form and hides other interfaces
|
|
||||||
* @function showRegister
|
|
||||||
* @example
|
|
||||||
* showRegister();
|
|
||||||
*/
|
|
||||||
export function showRegister(): void {
|
|
||||||
document.getElementById('login-form')!.style.display = 'none';
|
|
||||||
document.getElementById('register-form')!.style.display = 'flex';
|
|
||||||
document.getElementById('chat-interface')!.style.display = 'none';
|
|
||||||
clearAlerts();
|
|
||||||
document.getElementById("electron-title-bar")!.classList.add("color-surface");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the chat interface and hides authentication forms
|
|
||||||
* @function showChat
|
|
||||||
* @example
|
|
||||||
* showChat();
|
|
||||||
*/
|
|
||||||
export function showChat(): void {
|
|
||||||
document.getElementById('login-form')!.style.display = 'none';
|
|
||||||
document.getElementById('register-form')!.style.display = 'none';
|
|
||||||
document.getElementById('chat-interface')!.style.display = 'block';
|
|
||||||
loadMessages();
|
|
||||||
document.getElementById("electron-title-bar")!.classList.remove("color-surface");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears all alert messages from authentication forms
|
|
||||||
* @function clearAlerts
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
export function clearAlerts(): void {
|
|
||||||
document.getElementById('login-alerts')!.innerHTML = '';
|
|
||||||
document.getElementById('register-alerts')!.innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows an alert message in the specified container
|
|
||||||
* @param {string} containerId - ID of the container to show the alert in
|
|
||||||
* @param {string} message - Alert message to display
|
|
||||||
* @param {'success' | 'danger'} type - Type of alert (success or danger)
|
|
||||||
* @function showAlert
|
|
||||||
* @example
|
|
||||||
* showAlert('login-alerts', 'Login successful!', 'success');
|
|
||||||
*/
|
|
||||||
export function showAlert(containerId: string, message: string, type: "success" | "danger" = 'danger'): void {
|
|
||||||
const container = document.getElementById(containerId)!;
|
|
||||||
const alertDiv = document.createElement('div');
|
|
||||||
alertDiv.className = `alert alert-${type}`;
|
|
||||||
alertDiv.textContent = message;
|
|
||||||
container.appendChild(alertDiv);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles login form submission
|
|
||||||
* @async
|
|
||||||
* @function handleLogin
|
|
||||||
* @param {Event} e - Form submission event
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async function handleLogin(e: Event): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const usernameElement = document.getElementById('login-username') as HTMLInputElement;
|
|
||||||
const passwordElement = document.getElementById('login-password') as HTMLInputElement;
|
|
||||||
|
|
||||||
const username = usernameElement.value.trim();
|
|
||||||
const password = passwordElement.value.trim();
|
|
||||||
|
|
||||||
if (!username || !password) {
|
|
||||||
showAlert('login-alerts', 'Пожалуйста, заполните все поля', 'danger');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const request: LoginRequest = {
|
|
||||||
username: username,
|
|
||||||
password: password
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(request)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data: LoginResponse = await response.json();
|
|
||||||
// Store the JWT token
|
|
||||||
authToken = data.token;
|
|
||||||
currentUser = data.user;
|
|
||||||
showChat();
|
|
||||||
loadMessages(); // Start loading messages
|
|
||||||
initializeProfile(); // Initialize profile after login
|
|
||||||
} else {
|
|
||||||
const data: ErrorResponse = await response.json();
|
|
||||||
showAlert('login-alerts', data.message || 'Неверное имя пользователя или пароль', 'danger');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('login-alerts', 'Ошибка соединения с сервером', 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles registration form submission
|
|
||||||
* @async
|
|
||||||
* @function handleRegister
|
|
||||||
* @param {Event} e - Form submission event
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async function handleRegister(e: Event): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const usernameElement = document.getElementById('register-username') as HTMLInputElement;
|
|
||||||
const passwordElement = document.getElementById('register-password') as HTMLInputElement;
|
|
||||||
const confirmPasswordElement = document.getElementById('register-confirm-password') as HTMLInputElement;
|
|
||||||
|
|
||||||
const username = usernameElement.value.trim();
|
|
||||||
const password = passwordElement.value.trim();
|
|
||||||
const confirmPassword = confirmPasswordElement.value.trim();
|
|
||||||
|
|
||||||
if (!username || !password || !confirmPassword) {
|
|
||||||
showAlert('register-alerts', 'Пожалуйста, заполните все поля', 'danger');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
showAlert('register-alerts', 'Пароли не совпадают', 'danger');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (username.length < 3 || username.length > 20) {
|
|
||||||
showAlert('register-alerts', 'Имя пользователя должно быть от 3 до 20 символов', 'danger');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 5 || password.length > 50) {
|
|
||||||
showAlert('register-alerts', 'Пароль должен быть от 5 до 50 символов', 'danger');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const request: RegisterRequest = {
|
|
||||||
username: username,
|
|
||||||
password: password,
|
|
||||||
confirm_password: confirmPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/register`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(request)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Registration successful
|
|
||||||
showAlert('register-alerts', 'Регистрация прошла успешно! Теперь вы можете войти.', 'success');
|
|
||||||
setTimeout(() => {
|
|
||||||
showLogin();
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
const data: ErrorResponse = await response.json();
|
|
||||||
showAlert('register-alerts', data.message || 'Ошибка при регистрации', 'danger');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('register-alerts', 'Ошибка соединения с сервером', 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs out the current user and clears session data
|
|
||||||
* @async
|
|
||||||
* @function logout
|
|
||||||
* @example
|
|
||||||
* await logout();
|
|
||||||
*/
|
|
||||||
export async function logout(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await fetch(`${API_BASE_URL}/logout`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout error:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUser = null;
|
|
||||||
authToken = null;
|
|
||||||
showLogin();
|
|
||||||
clearAlerts();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the chat interface and initializes messaging
|
|
||||||
* @function loadChat
|
|
||||||
* @example
|
|
||||||
* loadChat();
|
|
||||||
*/
|
|
||||||
export function loadChat(): void {
|
|
||||||
showChat();
|
|
||||||
loadMessages();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks authentication status on page load
|
|
||||||
* @async
|
|
||||||
* @function checkAuthStatus
|
|
||||||
* @example
|
|
||||||
* await checkAuthStatus();
|
|
||||||
*/
|
|
||||||
export async function checkAuthStatus(): Promise<void> {
|
|
||||||
// For JWT, we don't have a persistent token on page load
|
|
||||||
// So we'll just show the login form
|
|
||||||
showLogin();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up authentication form event listeners
|
|
||||||
* @function setupAuthForms
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function setupAuthForms(): void {
|
|
||||||
document.getElementById('login-form-element')!.addEventListener('submit', handleLogin);
|
|
||||||
document.getElementById('register-form-element')!.addEventListener('submit', handleRegister);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes links
|
|
||||||
* @function setupLinks
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function setupLinks(): void {
|
|
||||||
document.getElementById("login-link")!.addEventListener("click", showLogin);
|
|
||||||
document.getElementById("register-link")!.addEventListener("click", showRegister);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize authentication forms
|
|
||||||
setupAuthForms();
|
|
||||||
setupLinks();
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Chat functionality and message management
|
|
||||||
* @description Handles message display, loading, sending, and real-time updates
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getAuthHeaders, currentUser, authToken } from "./auth";
|
|
||||||
import { API_BASE_URL } from "./config";
|
|
||||||
import { websocket } from "./websocket";
|
|
||||||
import type { Message, Messages, WebSocketMessage } from "./types";
|
|
||||||
import { formatTime } from "./utils/utils";
|
|
||||||
import { show as showContextMenu } from "./message-context-menu";
|
|
||||||
import { show as showUserProfileDialog } from "./user-profile-dialog";
|
|
||||||
import defaultAvatar from "./images/default-avatar.png";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a new message to the chat interface
|
|
||||||
* @param {Message} message - Message object to display
|
|
||||||
* @param {boolean} isAuthor - Whether the current user is the message author
|
|
||||||
* @function addMessage
|
|
||||||
* @example
|
|
||||||
* addMessage(messageData, messageData.username === currentUser.username);
|
|
||||||
*/
|
|
||||||
export function addMessage(message: Message, isAuthor: boolean): void {
|
|
||||||
const messagesContainer = document.querySelector('.chat-messages') as HTMLElement;
|
|
||||||
const messageDiv = document.createElement('div');
|
|
||||||
messageDiv.classList.add("message");
|
|
||||||
if (isAuthor) {
|
|
||||||
messageDiv.classList.add("sent");
|
|
||||||
} else {
|
|
||||||
messageDiv.classList.add("received");
|
|
||||||
}
|
|
||||||
messageDiv.dataset.id = `${message.id}`;
|
|
||||||
|
|
||||||
const messageInner = document.createElement('div');
|
|
||||||
messageInner.classList.add('message-inner');
|
|
||||||
|
|
||||||
// Add profile picture for received messages
|
|
||||||
if (!isAuthor) {
|
|
||||||
const profilePicDiv = document.createElement('div');
|
|
||||||
profilePicDiv.classList.add('message-profile-pic');
|
|
||||||
|
|
||||||
const profileImg = document.createElement('img');
|
|
||||||
profileImg.src = message.profile_picture || defaultAvatar;
|
|
||||||
profileImg.alt = message.username;
|
|
||||||
|
|
||||||
let errorLock = false;
|
|
||||||
|
|
||||||
profileImg.addEventListener("error", () => {
|
|
||||||
if (!errorLock) {
|
|
||||||
profileImg.src = defaultAvatar;
|
|
||||||
errorLock = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add click handler to profile picture
|
|
||||||
profileImg.style.cursor = 'pointer';
|
|
||||||
profileImg.addEventListener('click', () => {
|
|
||||||
showUserProfileDialog(message.username);
|
|
||||||
});
|
|
||||||
|
|
||||||
profilePicDiv.appendChild(profileImg);
|
|
||||||
messageDiv.appendChild(profilePicDiv);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthor) {
|
|
||||||
const usernameDiv = document.createElement('div');
|
|
||||||
usernameDiv.classList.add('message-username');
|
|
||||||
usernameDiv.textContent = message.username;
|
|
||||||
|
|
||||||
// Add click handler to username
|
|
||||||
usernameDiv.style.cursor = 'pointer';
|
|
||||||
usernameDiv.addEventListener('click', () => {
|
|
||||||
showUserProfileDialog(message.username);
|
|
||||||
});
|
|
||||||
|
|
||||||
messageInner.appendChild(usernameDiv);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add reply preview if this is a reply
|
|
||||||
if (message.reply_to) {
|
|
||||||
const replyDiv = document.createElement('div');
|
|
||||||
replyDiv.classList.add('message-reply');
|
|
||||||
replyDiv.innerHTML = `
|
|
||||||
<div class="reply-content">
|
|
||||||
<span class="reply-username">${message.reply_to.username}</span>
|
|
||||||
<span class="reply-text">${message.reply_to.content}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
messageInner.appendChild(replyDiv);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentDiv = document.createElement('div');
|
|
||||||
contentDiv.classList.add('message-content');
|
|
||||||
contentDiv.textContent = message.content;
|
|
||||||
messageInner.appendChild(contentDiv);
|
|
||||||
|
|
||||||
const timeDiv = document.createElement('div');
|
|
||||||
timeDiv.classList.add('message-time');
|
|
||||||
|
|
||||||
let timeText = formatTime(message.timestamp);
|
|
||||||
if (message.is_edited) {
|
|
||||||
timeText += ' (edited)';
|
|
||||||
}
|
|
||||||
timeDiv.textContent = timeText;
|
|
||||||
|
|
||||||
if (isAuthor && message.is_read) {
|
|
||||||
const checkIcon = document.createElement('span');
|
|
||||||
checkIcon.classList.add("material-symbols", "outlined");
|
|
||||||
timeDiv.appendChild(checkIcon);
|
|
||||||
}
|
|
||||||
|
|
||||||
messageInner.appendChild(timeDiv);
|
|
||||||
messageDiv.appendChild(messageInner);
|
|
||||||
messagesContainer.appendChild(messageDiv);
|
|
||||||
|
|
||||||
// Add right-click context menu
|
|
||||||
messageDiv.addEventListener('contextmenu', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
showContextMenu(message, e.clientX, e.clientY);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Прокрутка к новому сообщению
|
|
||||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads chat messages from the server
|
|
||||||
* @function loadMessages
|
|
||||||
* @example
|
|
||||||
* loadMessages();
|
|
||||||
*/
|
|
||||||
export function loadMessages(): void {
|
|
||||||
fetch(`${API_BASE_URL}/get_messages`, {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then((data: Messages) => {
|
|
||||||
if (data.messages && data.messages.length > 0) {
|
|
||||||
const messagesContainer = document.querySelector('.chat-messages') as HTMLElement;
|
|
||||||
|
|
||||||
const lastMessage = messagesContainer.lastElementChild as HTMLElement
|
|
||||||
let lastMessageId: number = 0
|
|
||||||
if (lastMessage) {
|
|
||||||
lastMessageId = Number(lastMessage.dataset.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем только новые сообщения
|
|
||||||
data.messages.forEach(msg => {
|
|
||||||
if (msg.id > lastMessageId) {
|
|
||||||
addMessage(msg, msg.username == currentUser!.username);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a message via WebSocket
|
|
||||||
* @function sendMessage
|
|
||||||
* @example
|
|
||||||
* sendMessage();
|
|
||||||
*/
|
|
||||||
export function sendMessage(): void {
|
|
||||||
const input = document.querySelector('.message-input') as HTMLInputElement;
|
|
||||||
const message = input.value.trim();
|
|
||||||
|
|
||||||
if (message) {
|
|
||||||
const payload: WebSocketMessage = {
|
|
||||||
data: {
|
|
||||||
content: message
|
|
||||||
},
|
|
||||||
credentials: {
|
|
||||||
scheme: "Bearer",
|
|
||||||
credentials: authToken!
|
|
||||||
},
|
|
||||||
type: "sendMessage"
|
|
||||||
}
|
|
||||||
|
|
||||||
let callback: ((e: MessageEvent) => void) | null = null
|
|
||||||
callback = (e) => {
|
|
||||||
websocket.removeEventListener("message", callback!);
|
|
||||||
const response: WebSocketMessage = JSON.parse(e.data)
|
|
||||||
console.log(response)
|
|
||||||
if (!response.error) {
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
websocket.addEventListener("message", callback);
|
|
||||||
|
|
||||||
websocket.send(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
document.getElementById('message-form')!.addEventListener('submit', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
sendMessage();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an existing message in the chat interface
|
|
||||||
* @param {Message} message - Updated message object
|
|
||||||
* @function updateMessage
|
|
||||||
*/
|
|
||||||
export function updateMessage(message: Message): void {
|
|
||||||
const messageElement = document.querySelector(`[data-id="${message.id}"]`) as HTMLElement;
|
|
||||||
if (!messageElement) return;
|
|
||||||
|
|
||||||
const contentDiv = messageElement.querySelector('.message-content') as HTMLElement;
|
|
||||||
const timeDiv = messageElement.querySelector('.message-time') as HTMLElement;
|
|
||||||
|
|
||||||
if (contentDiv) {
|
|
||||||
contentDiv.textContent = message.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeDiv) {
|
|
||||||
let timeText = formatTime(message.timestamp);
|
|
||||||
if (message.is_edited) {
|
|
||||||
timeText += ' (edited)';
|
|
||||||
}
|
|
||||||
timeDiv.textContent = timeText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a message from the chat interface
|
|
||||||
* @param {number} messageId - ID of the message to remove
|
|
||||||
* @function removeMessage
|
|
||||||
*/
|
|
||||||
export function removeMessage(messageId: number): void {
|
|
||||||
const messageElement = document.querySelector(`[data-id="${messageId}"]`) as HTMLElement;
|
|
||||||
if (messageElement) {
|
|
||||||
messageElement.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles WebSocket message updates
|
|
||||||
* @param {WebSocketMessage} response - WebSocket response
|
|
||||||
* @function handleWebSocketMessage
|
|
||||||
*/
|
|
||||||
export function handleWebSocketMessage(response: WebSocketMessage): void {
|
|
||||||
switch (response.type) {
|
|
||||||
case 'messageEdited':
|
|
||||||
if (response.data) {
|
|
||||||
updateMessage(response.data);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'messageDeleted':
|
|
||||||
if (response.data && response.data.message_id) {
|
|
||||||
removeMessage(response.data.message_id);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'newMessage':
|
|
||||||
if (response.data) {
|
|
||||||
const isAuthor = response.data.username === currentUser?.username;
|
|
||||||
addMessage(response.data, isAuthor);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Application configuration constants
|
|
||||||
* @description Contains all configuration values used throughout the application
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base API endpoint for all backend requests
|
|
||||||
* @type {string}
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
export const API_BASE_URL: string = '/api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full API URL including hostname and port for WebSocket connections
|
|
||||||
* @type {string}
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
export const API_FULL_BASE_URL: string = `${location.host}/api`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Application name displayed in UI and document title
|
|
||||||
* @type {string}
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
export const PRODUCT_NAME: string = "FromChat";
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
@use "common/colors" as *;
|
|
||||||
@use "common/material" as *;
|
|
||||||
|
|
||||||
.auth-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 100%;
|
|
||||||
padding: 2rem;
|
|
||||||
background-color: $color-dark-surface;
|
|
||||||
|
|
||||||
.auth-card {
|
|
||||||
background-color: $color-dark-surface-container;
|
|
||||||
color: $color-dark-on-surface;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 450px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-header {
|
|
||||||
margin: 0;
|
|
||||||
padding: 16px;
|
|
||||||
padding-bottom: 0;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
margin: 0;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 10px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-body {
|
|
||||||
padding: 25px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
|
|
||||||
form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
@use "common/colors" as *;
|
|
||||||
@use "common/material" as *;
|
|
||||||
|
|
||||||
#chat-interface {
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
background-color: $color-dark-surface-container;
|
|
||||||
color: white;
|
|
||||||
padding: 16px 16px;
|
|
||||||
justify-content: end;
|
|
||||||
width: fit-content;
|
|
||||||
z-index: 1000;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
|
|
||||||
.header-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logouts {
|
|
||||||
display: none;
|
|
||||||
list-style: none;
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
li {
|
|
||||||
a {
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-container {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.chat-main {
|
|
||||||
flex-grow: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
.chat-header {
|
|
||||||
padding: 16px;
|
|
||||||
background: $color-dark-surface-container;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
box-shadow: black 0 0 20px;
|
|
||||||
|
|
||||||
.chat-header-avatar {
|
|
||||||
width: 45px;
|
|
||||||
height: 45px;
|
|
||||||
border-radius: 20%;
|
|
||||||
object-fit: cover;
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-info {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
.info-chat {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin: 0 0 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #718096;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.online-status {
|
|
||||||
display: inline-block;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: $success;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
text-decoration: none;
|
|
||||||
color: white;
|
|
||||||
justify-content: end;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
position: absolute;
|
|
||||||
right: 2%;
|
|
||||||
top: 2%;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-messages {
|
|
||||||
flex: 1;
|
|
||||||
padding: 1rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background-color: $color-dark-surface-container-high;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
max-width: 70%;
|
|
||||||
position: relative;
|
|
||||||
width: fit-content;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.message-profile-pic {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-inner {
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
position: relative;
|
|
||||||
word-wrap: break-word;
|
|
||||||
|
|
||||||
.message-content {
|
|
||||||
word-wrap: break-word;
|
|
||||||
margin-bottom: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-reply {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
border-left: 3px solid $color-dark-primary;
|
|
||||||
|
|
||||||
.reply-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.2rem;
|
|
||||||
|
|
||||||
.reply-username {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: $color-dark-primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-text {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: $color-dark-on-surface-variant;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-time {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: $color-dark-on-surface-variant;
|
|
||||||
margin-top: 0.3rem;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.received .message-inner {
|
|
||||||
background-color: $color-dark-surface-container;
|
|
||||||
border-top-left-radius: 5px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.sent {
|
|
||||||
margin-left: auto;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
|
|
||||||
.message-inner {
|
|
||||||
background-color: $color-dark-primary-container;
|
|
||||||
color: $color-dark-on-primary-container;
|
|
||||||
border-top-right-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-time {
|
|
||||||
color: $color-dark-on-primary-container;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-username {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.3rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $color-dark-primary;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-wrapper {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
$height: 20px;
|
|
||||||
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -$height;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: $height;
|
|
||||||
background: linear-gradient(
|
|
||||||
0deg,
|
|
||||||
$color-dark-surface,
|
|
||||||
rgba(255, 255, 255, 0),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input {
|
|
||||||
margin: 0 20px 20px 20px;
|
|
||||||
background-color: $color-dark-surface-container;
|
|
||||||
border-radius: 40px;
|
|
||||||
|
|
||||||
.input-group {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
.message-input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 25px;
|
|
||||||
font-size: 1rem;
|
|
||||||
outline: none;
|
|
||||||
background-color: inherit;
|
|
||||||
caret-color: $color-dark-primary;
|
|
||||||
color: $color-dark-on-surface;
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-btn {
|
|
||||||
margin: 10px;
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: $color-dark-primary;
|
|
||||||
color: $color-dark-on-primary;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background-color 0.25s ease;
|
|
||||||
|
|
||||||
@include hoverStateLayer($background: $color-dark-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reply preview styles
|
|
||||||
.reply-preview {
|
|
||||||
background-color: $color-dark-surface;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border-left: 3px solid $color-dark-primary;
|
|
||||||
|
|
||||||
.reply-preview-content {
|
|
||||||
color: $color-dark-on-surface-variant;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
|
|
||||||
strong {
|
|
||||||
color: $color-dark-primary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
@use "common/material" as *;
|
|
||||||
|
|
||||||
#electron-title-bar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.electron {
|
|
||||||
#electron-title-bar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 8px;
|
|
||||||
height: 40px;
|
|
||||||
background-color: $color-dark-surface-container;
|
|
||||||
width: 100%;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
user-select: none;
|
|
||||||
z-index: 10;
|
|
||||||
transition: background-color 0.5s ease;
|
|
||||||
|
|
||||||
&.color-surface {
|
|
||||||
background-color: $color-dark-surface;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#window-title {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.platform-darwin .macos-padding {
|
|
||||||
width: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// .window-controls {
|
|
||||||
// -webkit-app-region: no-drag;
|
|
||||||
|
|
||||||
// .hidden {
|
|
||||||
// display: none;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
@use "common/colors" as *;
|
|
||||||
@use "common/material" as *;
|
|
||||||
|
|
||||||
|
|
||||||
// контейнер чата и панели с чатами
|
|
||||||
.all-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#profile {
|
|
||||||
display: none;
|
|
||||||
flex-direction: column;
|
|
||||||
z-index: 2000;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
position: fixed;
|
|
||||||
height: 100vh;
|
|
||||||
width: 27%;
|
|
||||||
background-color: $color-dark-surface-container;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.profileheader {
|
|
||||||
display: flex;
|
|
||||||
gap: 200px;
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
color: white;
|
|
||||||
border: solid 2px $color-dark-on-surface-variant;
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: 10px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.241);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#chat-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 0;
|
|
||||||
width: 40%;
|
|
||||||
background-color: $color-dark-surface-container;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 1000;
|
|
||||||
|
|
||||||
.chat-header-left {
|
|
||||||
display: flex;
|
|
||||||
color: white;
|
|
||||||
font-size: 25px;
|
|
||||||
background-color: $color-dark-surface-container;
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
#productname {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile {
|
|
||||||
font-size: 24px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: start;
|
|
||||||
flex-shrink: 0;
|
|
||||||
flex-grow: 0;
|
|
||||||
|
|
||||||
#closeprofile a {
|
|
||||||
text-decoration: none;
|
|
||||||
color: white;
|
|
||||||
border: solid 2px $color-dark-on-surface-variant;
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: 10px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.241);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
$size: 45px;
|
|
||||||
display: flex;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: $size;
|
|
||||||
height: $size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-tabs {
|
|
||||||
margin-top: 5px;
|
|
||||||
width: 100%;
|
|
||||||
--mdui-color-surface: $color-dark-surface-container;
|
|
||||||
--mdui-color-surface-variant: transparent;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 45px;
|
|
||||||
height: 45px;
|
|
||||||
border-radius: 20%;
|
|
||||||
object-fit: cover;
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mdui-bottom-app-bar {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
padding-left: 16px;
|
|
||||||
padding-right: 16px;
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
@use "common/colors" as *;
|
|
||||||
@use "common/material" as *;
|
|
||||||
|
|
||||||
#profile-dialog .content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24px;
|
|
||||||
min-width: 400px;
|
|
||||||
|
|
||||||
.header-top {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
position: relative;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 1px solid $color-dark-outline;
|
|
||||||
|
|
||||||
.profile-picture-container {
|
|
||||||
position: relative;
|
|
||||||
$size: 70px;
|
|
||||||
width: $size;
|
|
||||||
height: $size;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
#profile-picture {
|
|
||||||
width: $size;
|
|
||||||
height: $size;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-overlay {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mdui-text-field {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#profile-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
mdui-text-field {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid $color-dark-outline;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// User profile dialog content styles
|
|
||||||
#user-profile-dialog .content {
|
|
||||||
display: flex;
|
|
||||||
gap: 1.5rem;
|
|
||||||
|
|
||||||
.profile-picture-section {
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
.profile-picture {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 2px solid $color-dark-outline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-info {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
.username-section {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
|
|
||||||
.username {
|
|
||||||
margin: 0;
|
|
||||||
color: $color-dark-on-surface;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.online-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
&.online {
|
|
||||||
color: $success;
|
|
||||||
background-color: rgba(76, 175, 80, 0.1);
|
|
||||||
|
|
||||||
.online-indicator {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: $success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.offline {
|
|
||||||
color: $color-dark-on-surface-variant;
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
|
|
||||||
.offline-indicator {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: $color-dark-on-surface-variant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bio-section {
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
color: $color-dark-on-surface-variant;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bio-display {
|
|
||||||
color: $color-dark-on-surface;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background-color: $color-dark-surface;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid $color-dark-outline;
|
|
||||||
min-height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bio-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-stats {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
|
|
||||||
.stat {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
color: $color-dark-on-surface-variant;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
color: $color-dark-on-surface;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cropper Dialog Styles
|
|
||||||
#cropper-dialog {
|
|
||||||
.cropper-dialog-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
min-width: 500px;
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cropper-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 1px solid $color-dark-outline;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
color: $color-dark-on-surface;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cropper-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 400px;
|
|
||||||
background: $color-dark-surface-container;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
#cropper-area {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cropper-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid $color-dark-outline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
@use "common/colors" as *;
|
|
||||||
@use "common/material" as *;
|
|
||||||
|
|
||||||
#settings-dialog {
|
|
||||||
.fullscreen-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: calc(100vh - (1.5rem * 2));
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
#settings-dialog-inner {
|
|
||||||
max-width: 1200px;
|
|
||||||
max-height: 1000px;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#settings-menu {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
mdui-list {
|
|
||||||
max-width: 280px;
|
|
||||||
padding-right: 16px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.screen {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.settings-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transform: translateY(20px);
|
|
||||||
transition: opacity 0.3s ease, transform 0.3s ease, visibility 0.3s ease;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: translateY(0);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
color: $color-dark-on-surface;
|
|
||||||
}
|
|
||||||
|
|
||||||
mdui-text-field,
|
|
||||||
mdui-select,
|
|
||||||
mdui-switch,
|
|
||||||
mdui-button {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
mdui-switch {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 0;
|
|
||||||
border-bottom: 1px solid $color-dark-outline;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 8px 0;
|
|
||||||
color: $color-dark-on-surface-variant;
|
|
||||||
}
|
|
||||||
|
|
||||||
mdui-linear-progress {
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-in {
|
|
||||||
animation: fadeIn 0.3s ease forwards;
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
@use "material" as *;
|
|
||||||
|
|
||||||
.text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
&.alert-success {
|
|
||||||
background-color: #C6F6D5;
|
|
||||||
color: #22543D;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.alert-danger {
|
|
||||||
background-color: #FED7D7;
|
|
||||||
color: #742A2A;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
color: $color-dark-primary;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
button, input {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu {
|
|
||||||
position: fixed;
|
|
||||||
background-color: $color-dark-surface-container;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
z-index: 1000;
|
|
||||||
display: none;
|
|
||||||
min-width: 150px;
|
|
||||||
max-width: 200px;
|
|
||||||
white-space: nowrap;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
.context-menu-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: $color-dark-on-surface;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-symbols {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.pos-top-left {
|
|
||||||
transform-origin: top right;
|
|
||||||
}
|
|
||||||
&.pos-top-right {
|
|
||||||
transform-origin: top left;
|
|
||||||
}
|
|
||||||
&.pos-bottom-left {
|
|
||||||
transform-origin: bottom right;
|
|
||||||
}
|
|
||||||
&.pos-bottom-right {
|
|
||||||
transform-origin: bottom left;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.open {
|
|
||||||
animation: context-menu-open 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes context-menu-open {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dialog content styles
|
|
||||||
.dialog-content {
|
|
||||||
h3 {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
color: $color-dark-on-surface;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
@use "auth";
|
|
||||||
@use "chat";
|
|
||||||
@use "profile";
|
|
||||||
@use "settings";
|
|
||||||
@use "panelchat";
|
|
||||||
@use "common/animations";
|
|
||||||
@use "common/components";
|
|
||||||
@use "common/colors" as *;
|
|
||||||
@use "common/material" as *;
|
|
||||||
@use "electron";
|
|
||||||
|
|
||||||
@use "lib/fonts/montserrat";
|
|
||||||
@use "lib/fonts/material-symbols";
|
|
||||||
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Montserrat', sans-serif;
|
|
||||||
background-color: $color-dark-surface;
|
|
||||||
color: $color-dark-on-surface;
|
|
||||||
line-height: 1.6;
|
|
||||||
height: 100vh;
|
|
||||||
position: relative;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
#main-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mdui-dialog {
|
|
||||||
> *:first-child {
|
|
||||||
margin-block-start: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
> *:last-child {
|
|
||||||
margin-block-end: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import "../electron.d.ts";
|
|
||||||
import { PRODUCT_NAME } from "./config";
|
|
||||||
|
|
||||||
if (window.electronInterface !== undefined) {
|
|
||||||
console.log("Running in Electron");
|
|
||||||
document.documentElement.classList.add("electron", `platform-${window.electronInterface.platform}`);
|
|
||||||
document.getElementById("window-title")!.textContent = PRODUCT_NAME;
|
|
||||||
} else {
|
|
||||||
console.log("Running in normal browser");
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Application initialization logic
|
|
||||||
* @description Handles initial application setup and state
|
|
||||||
* @author FromChat Team
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { showLogin } from "./auth";
|
|
||||||
import { PRODUCT_NAME } from "./config";
|
|
||||||
|
|
||||||
showLogin();
|
|
||||||
document.getElementById("productname")!.textContent = PRODUCT_NAME;
|
|
||||||
document.title = PRODUCT_NAME;
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Left panel UI controls and interactions
|
|
||||||
* @description Handles chat collapse/expand, chat switching, and profile dialog
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Dialog } from "mdui/components/dialog";
|
|
||||||
import { loadProfilePicture } from "./profile/upload";
|
|
||||||
|
|
||||||
// сварачивание и разворачивание чата
|
|
||||||
const chatCollapseBtn = document.getElementById('hide-chat')!;
|
|
||||||
const chat1 = document.getElementById('chat-list-chat-1')!;
|
|
||||||
const chat2 = document.getElementById('chat-list-chat-2')!;
|
|
||||||
const chatInner = document.getElementById('chat-inner')!;
|
|
||||||
const chatName = document.getElementById('chat-name')!;
|
|
||||||
const profileButton = document.getElementById('profile-open')!;
|
|
||||||
const dialog = document.getElementById("profile-dialog") as Dialog;
|
|
||||||
const dialogClose = document.getElementById("profile-dialog-close")!;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up chat collapse functionality
|
|
||||||
* @function setupChatCollapse
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function setupChatCollapse(): void {
|
|
||||||
chatCollapseBtn.addEventListener('click', () => {
|
|
||||||
chatCollapseBtn.style.display = 'none';
|
|
||||||
chatInner.style.display = 'none';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up chat switching functionality
|
|
||||||
* @function setupChatSwitching
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function setupChatSwitching(): void {
|
|
||||||
chat1.addEventListener('click', () => {
|
|
||||||
chatCollapseBtn.style.display = 'flex';
|
|
||||||
chatInner.style.display = 'flex';
|
|
||||||
chatName.textContent = 'общий чат';
|
|
||||||
});
|
|
||||||
|
|
||||||
chat2.addEventListener('click', () => {
|
|
||||||
chatCollapseBtn.style.display = 'flex';
|
|
||||||
chatInner.style.display = 'flex';
|
|
||||||
chatName.textContent = 'общий чат 2';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up profile dialog functionality
|
|
||||||
* @function setupProfileDialog
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function setupProfileDialog(): void {
|
|
||||||
profileButton.addEventListener('click', () => {
|
|
||||||
dialog.open = true;
|
|
||||||
loadProfilePicture();
|
|
||||||
});
|
|
||||||
|
|
||||||
dialogClose.addEventListener("click", () => {
|
|
||||||
dialog.open = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setupChatCollapse();
|
|
||||||
setupChatSwitching();
|
|
||||||
setupProfileDialog();
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Message context menu functionality
|
|
||||||
* @description Handles right-click context menu for message actions (edit, delete, reply)
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { currentUser, authToken } from "./auth";
|
|
||||||
import { websocket } from "./websocket";
|
|
||||||
import type { Message, WebSocketMessage } from "./types";
|
|
||||||
import { showSuccess, showError } from "./utils/notification";
|
|
||||||
import { delay } from "./utils/utils";
|
|
||||||
import type { Dialog } from "mdui/components/dialog";
|
|
||||||
import type { TextField } from "mdui/components/text-field";
|
|
||||||
|
|
||||||
|
|
||||||
let menu = document.getElementById("message-context-menu")!;
|
|
||||||
let editDialog = document.getElementById("edit-message-dialog") as Dialog;
|
|
||||||
let replyDialog = document.getElementById("reply-message-dialog") as Dialog;
|
|
||||||
let currentMessage: Message | null = null;
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
bindEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds event listeners
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function bindEvents(): void {
|
|
||||||
// Context menu events
|
|
||||||
menu?.addEventListener('click', (e) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
const action = target.closest('.context-menu-item')?.getAttribute('data-action');
|
|
||||||
|
|
||||||
if (action && currentMessage) {
|
|
||||||
handleAction(action, currentMessage);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close menu when clicking outside
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!menu?.contains(e.target as Node)) {
|
|
||||||
hide();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Edit dialog events
|
|
||||||
const editCancelBtn = editDialog?.querySelector('#edit-cancel');
|
|
||||||
const editSaveBtn = editDialog?.querySelector('#edit-save');
|
|
||||||
|
|
||||||
editCancelBtn?.addEventListener('click', () => hideEditDialog());
|
|
||||||
editSaveBtn?.addEventListener('click', () => saveEdit());
|
|
||||||
|
|
||||||
// Reply dialog events
|
|
||||||
const replyCancelBtn = replyDialog?.querySelector('#reply-cancel');
|
|
||||||
const replySendBtn = replyDialog?.querySelector('#reply-send');
|
|
||||||
|
|
||||||
replyCancelBtn?.addEventListener('click', () => hideReplyDialog());
|
|
||||||
replySendBtn?.addEventListener('click', () => sendReply());
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
hide();
|
|
||||||
hideEditDialog();
|
|
||||||
hideReplyDialog();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the context menu at the specified position
|
|
||||||
* @param {Message} message - The message to show menu for
|
|
||||||
* @param {number} x - X coordinate
|
|
||||||
* @param {number} y - Y coordinate
|
|
||||||
*/
|
|
||||||
export function show(message: Message, x: number, y: number): void {
|
|
||||||
currentMessage = message;
|
|
||||||
|
|
||||||
// Show delete for own messages and for owner on any message
|
|
||||||
const editItem = menu.querySelector('[data-action="edit"]') as HTMLElement;
|
|
||||||
const deleteItem = menu.querySelector('[data-action="delete"]') as HTMLElement;
|
|
||||||
|
|
||||||
const isAuthor = message.username === currentUser?.username;
|
|
||||||
const isOwner = !!currentUser?.admin;
|
|
||||||
|
|
||||||
editItem.style.display = isAuthor ? 'flex' : 'none';
|
|
||||||
deleteItem.style.display = (isAuthor || isOwner) ? 'flex' : 'none';
|
|
||||||
|
|
||||||
// Position the menu properly
|
|
||||||
menu.style.display = 'block';
|
|
||||||
|
|
||||||
let menuWidth = menu.offsetWidth;
|
|
||||||
let menuHeight = menu.offsetHeight;
|
|
||||||
|
|
||||||
let adjustedX = x;
|
|
||||||
let adjustedY = y;
|
|
||||||
let vertical = "top";
|
|
||||||
let horizontal = "right";
|
|
||||||
|
|
||||||
// Adjust horizontal position if menu would go off-screen
|
|
||||||
if (x + menuWidth > window.innerWidth) {
|
|
||||||
adjustedX = x - menuWidth;
|
|
||||||
horizontal = "left";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust vertical position if menu would go off-screen
|
|
||||||
if (y + menuHeight > window.innerHeight) {
|
|
||||||
adjustedY = y - menuHeight;
|
|
||||||
vertical = "bottom";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure menu doesn't go off the left or top edges
|
|
||||||
adjustedX = Math.max(0, adjustedX);
|
|
||||||
adjustedY = Math.max(0, adjustedY);
|
|
||||||
|
|
||||||
menu.style.left = `${adjustedX}px`;
|
|
||||||
menu.style.top = `${adjustedY}px`;
|
|
||||||
menu.classList.add(`pos-${vertical}-${horizontal}`, "open");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hides the context menu
|
|
||||||
*/
|
|
||||||
export function hide(): void {
|
|
||||||
menu.style.display = 'none';
|
|
||||||
menu.classList.forEach((name) => {
|
|
||||||
if (name.match(/pos-\w+-\w+/)) {
|
|
||||||
menu.classList.remove(name);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
currentMessage = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles context menu actions
|
|
||||||
* @param {string} action - The action to perform
|
|
||||||
* @param {Message} message - The message to act on
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function handleAction(action: string, message: Message): void {
|
|
||||||
hide();
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'edit':
|
|
||||||
showEditDialog(message);
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
deleteMessage(message);
|
|
||||||
break;
|
|
||||||
case 'reply':
|
|
||||||
showReplyDialog(message);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the edit dialog
|
|
||||||
* @param {Message} message - The message to edit
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async function showEditDialog(message: Message): Promise<void> {
|
|
||||||
const textField = editDialog.querySelector('#edit-message-input') as TextField;
|
|
||||||
textField.value = message.content;
|
|
||||||
|
|
||||||
currentMessage = message;
|
|
||||||
editDialog.open = true;
|
|
||||||
|
|
||||||
// Focus the text field
|
|
||||||
await delay(100);
|
|
||||||
textField?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hides the edit dialog
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function hideEditDialog(): void {
|
|
||||||
editDialog.open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the edited message
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function saveEdit(): void {
|
|
||||||
if (!currentMessage) return;
|
|
||||||
|
|
||||||
const textField = editDialog.querySelector('#edit-message-input') as TextField;
|
|
||||||
const newContent = textField?.value?.trim() || '';
|
|
||||||
|
|
||||||
if (!newContent) {
|
|
||||||
showError('Message cannot be empty');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: WebSocketMessage = {
|
|
||||||
type: "editMessage",
|
|
||||||
data: {
|
|
||||||
message_id: currentMessage.id,
|
|
||||||
content: newContent
|
|
||||||
},
|
|
||||||
credentials: {
|
|
||||||
scheme: "Bearer",
|
|
||||||
credentials: authToken!
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let callback: ((e: MessageEvent) => void) | null = null;
|
|
||||||
callback = (e) => {
|
|
||||||
websocket.removeEventListener("message", callback!);
|
|
||||||
const response: WebSocketMessage = JSON.parse(e.data);
|
|
||||||
|
|
||||||
if (response.error) {
|
|
||||||
showError(response.error.detail);
|
|
||||||
} else {
|
|
||||||
showSuccess('Message edited successfully');
|
|
||||||
hideEditDialog();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
websocket.addEventListener("message", callback);
|
|
||||||
websocket.send(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the reply dialog
|
|
||||||
* @param {Message} message - The message to reply to
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async function showReplyDialog(message: Message): Promise<void> {
|
|
||||||
const preview = replyDialog.querySelector('#reply-preview') as HTMLElement;
|
|
||||||
preview.innerHTML = `
|
|
||||||
<div class="reply-preview-content">
|
|
||||||
<strong>${message.username}</strong>: ${message.content}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
currentMessage = message;
|
|
||||||
replyDialog.open = true;
|
|
||||||
|
|
||||||
// Focus the text field
|
|
||||||
await delay(100);
|
|
||||||
const textField = replyDialog?.querySelector('#reply-message-input') as TextField;
|
|
||||||
textField?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hides the reply dialog
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function hideReplyDialog(): void {
|
|
||||||
replyDialog.open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends the reply message
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function sendReply(): void {
|
|
||||||
if (!currentMessage) return;
|
|
||||||
|
|
||||||
const textField = replyDialog.querySelector('#reply-message-input') as TextField;
|
|
||||||
const content = textField?.value?.trim() || '';
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
showError('Reply cannot be empty');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: WebSocketMessage = {
|
|
||||||
type: "replyMessage",
|
|
||||||
data: {
|
|
||||||
content: content,
|
|
||||||
reply_to_id: currentMessage.id
|
|
||||||
},
|
|
||||||
credentials: {
|
|
||||||
scheme: "Bearer",
|
|
||||||
credentials: authToken!
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let callback: ((e: MessageEvent) => void) | null = null;
|
|
||||||
callback = (e) => {
|
|
||||||
websocket.removeEventListener("message", callback!);
|
|
||||||
const response: WebSocketMessage = JSON.parse(e.data);
|
|
||||||
|
|
||||||
if (response.error) {
|
|
||||||
showError(response.error.detail);
|
|
||||||
} else {
|
|
||||||
showSuccess('Reply sent successfully');
|
|
||||||
hideReplyDialog();
|
|
||||||
if (textField) {
|
|
||||||
textField.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
websocket.addEventListener("message", callback);
|
|
||||||
websocket.send(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a message
|
|
||||||
* @param {Message} message - The message to delete
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function deleteMessage(message: Message): void {
|
|
||||||
if (!confirm('Are you sure you want to delete this message?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: WebSocketMessage = {
|
|
||||||
type: "deleteMessage",
|
|
||||||
data: {
|
|
||||||
message_id: message.id
|
|
||||||
},
|
|
||||||
credentials: {
|
|
||||||
scheme: "Bearer",
|
|
||||||
credentials: authToken!
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let callback: ((e: MessageEvent) => void) | null = null;
|
|
||||||
callback = (e) => {
|
|
||||||
websocket.removeEventListener("message", callback!);
|
|
||||||
const response: WebSocketMessage = JSON.parse(e.data);
|
|
||||||
|
|
||||||
if (response.error) {
|
|
||||||
showError(response.error.detail);
|
|
||||||
} else {
|
|
||||||
showSuccess('Message deleted successfully');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
websocket.addEventListener("message", callback);
|
|
||||||
websocket.send(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Profile module entry point and initialization
|
|
||||||
* @description Coordinates profile system initialization and form handling
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Dialog } from "mdui/components/dialog";
|
|
||||||
import { loadProfileData } from './profile/editor';
|
|
||||||
import { loadProfilePicture, initializeProfileUpload } from "./profile/upload";
|
|
||||||
import { initializeProfileEditor } from './profile/editor';
|
|
||||||
|
|
||||||
// Handle profile form submission
|
|
||||||
const form = document.getElementById("profile-form")!;
|
|
||||||
const dialog = document.getElementById("profile-dialog") as Dialog;
|
|
||||||
|
|
||||||
form.addEventListener("submit", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// TODO: Process form data if needed
|
|
||||||
// For now, just close the dialog
|
|
||||||
dialog.open = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes profile functionality after user login
|
|
||||||
* @function initializeProfile
|
|
||||||
* @example
|
|
||||||
* // Called after successful authentication
|
|
||||||
* initializeProfile();
|
|
||||||
*/
|
|
||||||
export function initializeProfile(): void {
|
|
||||||
// Initialize profile modules
|
|
||||||
initializeProfileUpload();
|
|
||||||
initializeProfileEditor();
|
|
||||||
|
|
||||||
// Load profile data
|
|
||||||
Promise.all([
|
|
||||||
loadProfilePicture(),
|
|
||||||
loadProfileData()
|
|
||||||
]).catch(error => {
|
|
||||||
console.error('Error initializing profile:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Profile-related API calls
|
|
||||||
* @description Handles all profile-related HTTP requests to the backend
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getAuthHeaders } from '../auth';
|
|
||||||
import type { ProfileData, UploadResponse } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads user profile data from the server
|
|
||||||
* @async
|
|
||||||
* @function loadProfile
|
|
||||||
* @returns {Promise<ProfileData | null>} User profile data or null if failed
|
|
||||||
* @example
|
|
||||||
* const profile = await loadProfile();
|
|
||||||
* if (profile) {
|
|
||||||
* console.log('User nickname:', profile.nickname);
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export async function loadProfile(): Promise<ProfileData | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/user/profile', {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading profile:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads a profile picture to the server
|
|
||||||
* @async
|
|
||||||
* @function uploadProfilePicture
|
|
||||||
* @param {Blob} file - The image file to upload
|
|
||||||
* @returns {Promise<UploadResponse | null>} Upload response with URL or null if failed
|
|
||||||
* @example
|
|
||||||
* const fileInput = document.getElementById('file-input');
|
|
||||||
* const file = fileInput.files[0];
|
|
||||||
* const result = await uploadProfilePicture(file);
|
|
||||||
* if (result) {
|
|
||||||
* console.log('Uploaded to:', result.profile_picture_url);
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export async function uploadProfilePicture(file: Blob): Promise<UploadResponse | null> {
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('profile_picture', file, 'profile_picture.jpg');
|
|
||||||
|
|
||||||
const response = await fetch('/api/upload-profile-picture', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
headers: getAuthHeaders(false)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates user profile information
|
|
||||||
* @async
|
|
||||||
* @function updateProfile
|
|
||||||
* @param {Partial<ProfileData>} data - Profile data to update
|
|
||||||
* @returns {Promise<boolean>} True if update was successful, false otherwise
|
|
||||||
* @example
|
|
||||||
* const success = await updateProfile({
|
|
||||||
* nickname: 'New Name',
|
|
||||||
* description: 'Updated bio'
|
|
||||||
* });
|
|
||||||
* if (success) {
|
|
||||||
* console.log('Profile updated successfully');
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export async function updateProfile(data: Partial<ProfileData>): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/user/profile', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating profile:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates user bio
|
|
||||||
* @async
|
|
||||||
* @function updateBio
|
|
||||||
* @param {string} bio - New bio text
|
|
||||||
* @returns {Promise<boolean>} True if update was successful, false otherwise
|
|
||||||
* @example
|
|
||||||
* const success = await updateBio('My new bio text');
|
|
||||||
* if (success) {
|
|
||||||
* console.log('Bio updated successfully');
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export async function updateBio(bio: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/user/bio', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
...getAuthHeaders(),
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ bio })
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating bio:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Profile editing functionality
|
|
||||||
* @description Handles profile form editing and MDUI text field integration
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { updateProfile } from './api';
|
|
||||||
import { loadProfile } from './api';
|
|
||||||
import { showSuccess, showError } from '../utils/notification';
|
|
||||||
import type { TextField } from 'mdui/components/text-field';
|
|
||||||
|
|
||||||
let profileForm = document.getElementById('profile-form')!;
|
|
||||||
let nicknameField = document.getElementById('username-field') as unknown as TextField;
|
|
||||||
let descriptionField = document.getElementById('description-field') as unknown as TextField;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialization state flag
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
let isInitialized = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the username field value
|
|
||||||
* @param {string} value - The username value to set
|
|
||||||
* @function setUsernameValue
|
|
||||||
* @example
|
|
||||||
* setUsernameValue('John Doe');
|
|
||||||
*/
|
|
||||||
export function setUsernameValue(value: string): void {
|
|
||||||
if (nicknameField && nicknameField.value !== undefined) {
|
|
||||||
nicknameField.value = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the description field value
|
|
||||||
* @param {string} value - The description value to set
|
|
||||||
* @function setDescriptionValue
|
|
||||||
* @example
|
|
||||||
* setDescriptionValue('Software Developer');
|
|
||||||
*/
|
|
||||||
export function setDescriptionValue(value: string): void {
|
|
||||||
if (descriptionField && descriptionField.value !== undefined) {
|
|
||||||
descriptionField.value = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the current username field value
|
|
||||||
* @returns {string} The current username value
|
|
||||||
* @function getUsernameValue
|
|
||||||
* @example
|
|
||||||
* const username = getUsernameValue();
|
|
||||||
* console.log('Current username:', username);
|
|
||||||
*/
|
|
||||||
export function getUsernameValue(): string {
|
|
||||||
if (nicknameField && nicknameField.value !== undefined) {
|
|
||||||
return nicknameField.value;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the current description field value
|
|
||||||
* @returns {string} The current description value
|
|
||||||
* @function getDescriptionValue
|
|
||||||
* @example
|
|
||||||
* const description = getDescriptionValue();
|
|
||||||
* console.log('Current description:', description);
|
|
||||||
*/
|
|
||||||
export function getDescriptionValue(): string {
|
|
||||||
if (descriptionField && descriptionField.value !== undefined) {
|
|
||||||
return descriptionField.value;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads profile data from the server and populates the form fields
|
|
||||||
* @async
|
|
||||||
* @function loadProfileData
|
|
||||||
* @example
|
|
||||||
* await loadProfileData();
|
|
||||||
*/
|
|
||||||
export async function loadProfileData(): Promise<void> {
|
|
||||||
const userData = await loadProfile();
|
|
||||||
if (userData) {
|
|
||||||
if (userData.nickname) {
|
|
||||||
setUsernameValue(userData.nickname);
|
|
||||||
}
|
|
||||||
if (userData.description) {
|
|
||||||
setDescriptionValue(userData.description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles profile form submission
|
|
||||||
* @async
|
|
||||||
* @function handleFormSubmission
|
|
||||||
* @param {Event} e - Form submission event
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async function handleFormSubmission(e: Event): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const nickname = getUsernameValue();
|
|
||||||
const description = getDescriptionValue();
|
|
||||||
|
|
||||||
if (nickname || description) {
|
|
||||||
const success = await updateProfile({
|
|
||||||
nickname: nickname || undefined,
|
|
||||||
description: description || undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
showSuccess('Профиль обновлен!');
|
|
||||||
} else {
|
|
||||||
showError('Ошибка при обновлении профиля');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up form submission handler
|
|
||||||
* @function setupFormHandler
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function setupFormHandler(): void {
|
|
||||||
if (isInitialized) return;
|
|
||||||
|
|
||||||
// Get DOM elements
|
|
||||||
profileForm = document.getElementById('profile-form')!;
|
|
||||||
nicknameField = document.getElementById('username-field') as any;
|
|
||||||
descriptionField = document.getElementById('description-field') as any;
|
|
||||||
|
|
||||||
profileForm.addEventListener('submit', handleFormSubmission);
|
|
||||||
|
|
||||||
isInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes profile editor functionality
|
|
||||||
* @function initializeProfileEditor
|
|
||||||
* @example
|
|
||||||
* initializeProfileEditor();
|
|
||||||
*/
|
|
||||||
export function initializeProfileEditor(): void {
|
|
||||||
setupFormHandler();
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Canvas-based image cropping component
|
|
||||||
* @description Provides circular image cropping functionality with drag support
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Size2D } from "../types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Image cropper class for circular profile picture cropping
|
|
||||||
* @class ImageCropper
|
|
||||||
*/
|
|
||||||
export class ImageCropper {
|
|
||||||
private canvas: HTMLCanvasElement;
|
|
||||||
private ctx: CanvasRenderingContext2D;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Image element to be cropped
|
|
||||||
* @type {HTMLImageElement}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private image!: HTMLImageElement;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Size of the crop area (diameter)
|
|
||||||
* @type {number}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private cropSize: number = 200;
|
|
||||||
private isDragging: boolean = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starting position of the drag operation
|
|
||||||
* @type {Size2D}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private dragStart: Size2D = { x: 0, y: 0 };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current position of the crop area
|
|
||||||
* @type {Size2D}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private cropPosition: Size2D = { x: 0, y: 0 };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new ImageCropper instance
|
|
||||||
* @param {HTMLElement} container - Container element to append the canvas to
|
|
||||||
* @constructor
|
|
||||||
* @example
|
|
||||||
* const cropper = new ImageCropper(document.getElementById('cropper-area'));
|
|
||||||
*/
|
|
||||||
constructor(container: HTMLElement) {
|
|
||||||
this.canvas = document.createElement('canvas');
|
|
||||||
this.canvas.width = this.cropSize;
|
|
||||||
this.canvas.height = this.cropSize;
|
|
||||||
this.ctx = this.canvas.getContext('2d')!;
|
|
||||||
|
|
||||||
container.appendChild(this.canvas);
|
|
||||||
this.setupEventListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up mouse and touch event listeners
|
|
||||||
* @function setupEventListeners
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private setupEventListeners(): void {
|
|
||||||
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
|
|
||||||
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
|
|
||||||
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
|
|
||||||
this.canvas.addEventListener('touchstart', this.onTouchStart.bind(this));
|
|
||||||
this.canvas.addEventListener('touchmove', this.onTouchMove.bind(this));
|
|
||||||
this.canvas.addEventListener('touchend', this.onTouchEnd.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles mouse down events
|
|
||||||
* @param {MouseEvent} e - Mouse event
|
|
||||||
* @function onMouseDown
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private onMouseDown(e: MouseEvent): void {
|
|
||||||
this.isDragging = true;
|
|
||||||
this.dragStart = { x: e.clientX, y: e.clientY };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles mouse move events during dragging
|
|
||||||
* @param {MouseEvent} e - Mouse event
|
|
||||||
* @function onMouseMove
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private onMouseMove(e: MouseEvent): void {
|
|
||||||
if (!this.isDragging) return;
|
|
||||||
|
|
||||||
const deltaX = e.clientX - this.dragStart.x;
|
|
||||||
const deltaY = e.clientY - this.dragStart.y;
|
|
||||||
|
|
||||||
this.cropPosition.x += deltaX;
|
|
||||||
this.cropPosition.y += deltaY;
|
|
||||||
|
|
||||||
this.dragStart = { x: e.clientX, y: e.clientY };
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles mouse up events
|
|
||||||
* @function onMouseUp
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private onMouseUp(): void {
|
|
||||||
this.isDragging = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles touch start events
|
|
||||||
* @param {TouchEvent} e - Touch event
|
|
||||||
* @function onTouchStart
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private onTouchStart(e: TouchEvent): void {
|
|
||||||
e.preventDefault();
|
|
||||||
const touch = e.touches[0];
|
|
||||||
this.isDragging = true;
|
|
||||||
this.dragStart = { x: touch.clientX, y: touch.clientY };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles touch move events during dragging
|
|
||||||
* @param {TouchEvent} e - Touch event
|
|
||||||
* @function onTouchMove
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private onTouchMove(e: TouchEvent): void {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!this.isDragging) return;
|
|
||||||
|
|
||||||
const touch = e.touches[0];
|
|
||||||
const deltaX = touch.clientX - this.dragStart.x;
|
|
||||||
const deltaY = touch.clientY - this.dragStart.y;
|
|
||||||
|
|
||||||
this.cropPosition.x += deltaX;
|
|
||||||
this.cropPosition.y += deltaY;
|
|
||||||
|
|
||||||
this.dragStart = { x: touch.clientX, y: touch.clientY };
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles touch end events
|
|
||||||
* @function onTouchEnd
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private onTouchEnd(): void {
|
|
||||||
this.isDragging = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads an image file for cropping
|
|
||||||
* @param {File} file - Image file to load
|
|
||||||
* @returns {Promise<void>} Promise that resolves when image is loaded
|
|
||||||
* @async
|
|
||||||
* @example
|
|
||||||
* await cropper.loadImage(fileInput.files[0]);
|
|
||||||
*/
|
|
||||||
loadImage(file: File): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.image = new Image();
|
|
||||||
this.image.onload = () => {
|
|
||||||
this.render();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
this.image.src = URL.createObjectURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the image with circular crop overlay
|
|
||||||
* @function render
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private render(): void {
|
|
||||||
if (!this.image) return;
|
|
||||||
|
|
||||||
// Clear canvas
|
|
||||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
||||||
|
|
||||||
// Calculate crop area
|
|
||||||
const scale = Math.max(this.cropSize / this.image.width, this.cropSize / this.image.height);
|
|
||||||
const scaledWidth = this.image.width * scale;
|
|
||||||
const scaledHeight = this.image.height * scale;
|
|
||||||
|
|
||||||
// Draw image
|
|
||||||
this.ctx.save();
|
|
||||||
this.ctx.globalCompositeOperation = 'source-over';
|
|
||||||
this.ctx.drawImage(
|
|
||||||
this.image,
|
|
||||||
this.cropPosition.x,
|
|
||||||
this.cropPosition.y,
|
|
||||||
scaledWidth,
|
|
||||||
scaledHeight
|
|
||||||
);
|
|
||||||
this.ctx.restore();
|
|
||||||
|
|
||||||
// Draw crop overlay
|
|
||||||
this.ctx.save();
|
|
||||||
this.ctx.globalCompositeOperation = 'destination-in';
|
|
||||||
this.ctx.beginPath();
|
|
||||||
this.ctx.arc(this.cropSize / 2, this.cropSize / 2, this.cropSize / 2, 0, 2 * Math.PI);
|
|
||||||
this.ctx.fill();
|
|
||||||
this.ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the cropped image as a data URL
|
|
||||||
* @returns {string} Data URL of the cropped image
|
|
||||||
* @example
|
|
||||||
* const croppedImage = cropper.getCroppedImage();
|
|
||||||
* // Use croppedImage as src for an img element
|
|
||||||
*/
|
|
||||||
getCroppedImage(): string {
|
|
||||||
return this.canvas.toDataURL('image/jpeg', 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroys the cropper and removes the canvas from DOM
|
|
||||||
* @function destroy
|
|
||||||
* @example
|
|
||||||
* cropper.destroy();
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
if (this.canvas.parentNode) {
|
|
||||||
this.canvas.parentNode.removeChild(this.canvas);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Profile-specific type definitions
|
|
||||||
* @description Contains type definitions for profile-related functionality
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User profile data structure
|
|
||||||
* @interface ProfileData
|
|
||||||
* @property {string} [profile_picture] - URL to user's profile picture
|
|
||||||
* @property {string} [nickname] - User's display name
|
|
||||||
* @property {string} [description] - User's bio or description
|
|
||||||
*/
|
|
||||||
export interface ProfileData {
|
|
||||||
profile_picture?: string;
|
|
||||||
nickname?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Profile picture upload response structure
|
|
||||||
* @interface UploadResponse
|
|
||||||
* @property {string} profile_picture_url - URL to the uploaded profile picture
|
|
||||||
*/
|
|
||||||
export interface UploadResponse {
|
|
||||||
profile_picture_url: string;
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Profile picture upload functionality
|
|
||||||
* @description Handles file selection, image cropping, and profile picture upload
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Dialog } from "mdui/components/dialog";
|
|
||||||
import { ImageCropper } from './image-cropper';
|
|
||||||
import { uploadProfilePicture } from './api';
|
|
||||||
import { loadProfile } from './api';
|
|
||||||
import { showSuccess, showError } from '../utils/notification';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global image cropper instance
|
|
||||||
* @type {ImageCropper | null}
|
|
||||||
*/
|
|
||||||
let cropper: ImageCropper | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialization state flag
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
let isInitialized = false;
|
|
||||||
|
|
||||||
let cropperDialog = document.getElementById('cropper-dialog') as Dialog;
|
|
||||||
let fileInput = document.getElementById('pfp-file-input') as HTMLInputElement;
|
|
||||||
let uploadBtn = document.getElementById('upload-pfp-btn')!;
|
|
||||||
let cropSaveBtn = document.getElementById('crop-save')!;
|
|
||||||
let cropCancelBtn = document.getElementById('crop-cancel')!;
|
|
||||||
let cropperCloseBtn = document.getElementById('cropper-close')!;
|
|
||||||
let cropperArea = document.getElementById('cropper-area')!;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the image cropper with the selected file
|
|
||||||
* @async
|
|
||||||
* @function openCropper
|
|
||||||
* @param {File} file - The image file to crop
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async function openCropper(file: File): Promise<void> {
|
|
||||||
// Clear previous cropper
|
|
||||||
cropperArea.innerHTML = '';
|
|
||||||
|
|
||||||
// Create new cropper
|
|
||||||
cropper = new ImageCropper(cropperArea);
|
|
||||||
|
|
||||||
// Load image
|
|
||||||
await cropper.loadImage(file);
|
|
||||||
|
|
||||||
// Open dialog
|
|
||||||
cropperDialog.open = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Closes the image cropper and cleans up resources
|
|
||||||
* @function closeCropper
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function closeCropper(): void {
|
|
||||||
cropperDialog.open = false;
|
|
||||||
cropperArea.innerHTML = '';
|
|
||||||
if (cropper) {
|
|
||||||
cropper.destroy();
|
|
||||||
cropper = null;
|
|
||||||
}
|
|
||||||
fileInput.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the cropped image and uploads it to the server
|
|
||||||
* @async
|
|
||||||
* @function saveCroppedImage
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async function saveCroppedImage(): Promise<void> {
|
|
||||||
if (!cropper) return;
|
|
||||||
|
|
||||||
const croppedImageData = cropper.getCroppedImage();
|
|
||||||
|
|
||||||
// Convert data URL to blob
|
|
||||||
const response = await fetch(croppedImageData);
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
const result = await uploadProfilePicture(blob);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
// Update profile picture display
|
|
||||||
const profilePicture = document.getElementById('profile-picture') as HTMLImageElement;
|
|
||||||
profilePicture.src = result.profile_picture_url + '?t=' + Date.now(); // Cache bust
|
|
||||||
|
|
||||||
// Close cropper
|
|
||||||
closeCropper();
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
showSuccess('Фото профиля обновлено!');
|
|
||||||
} else {
|
|
||||||
showError('Ошибка при загрузке фото');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up event listeners for upload functionality
|
|
||||||
* @function setupEventListeners
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function setupEventListeners(): void {
|
|
||||||
if (isInitialized) return;
|
|
||||||
|
|
||||||
uploadBtn.addEventListener('click', () => {
|
|
||||||
fileInput.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', (e) => {
|
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
|
||||||
if (file) {
|
|
||||||
openCropper(file);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cropSaveBtn.addEventListener('click', () => {
|
|
||||||
saveCroppedImage();
|
|
||||||
});
|
|
||||||
|
|
||||||
cropCancelBtn.addEventListener('click', () => {
|
|
||||||
closeCropper();
|
|
||||||
});
|
|
||||||
|
|
||||||
cropperCloseBtn.addEventListener('click', () => {
|
|
||||||
closeCropper();
|
|
||||||
});
|
|
||||||
|
|
||||||
isInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads and displays the user's profile picture
|
|
||||||
* @async
|
|
||||||
* @function loadProfilePicture
|
|
||||||
* @example
|
|
||||||
* await loadProfilePicture();
|
|
||||||
*/
|
|
||||||
export async function loadProfilePicture(): Promise<void> {
|
|
||||||
const userData = await loadProfile();
|
|
||||||
if (userData?.profile_picture) {
|
|
||||||
const url = `${userData.profile_picture}?t=${Date.now()}`;
|
|
||||||
|
|
||||||
const profilePicture = document.getElementById('profile-picture') as HTMLImageElement;
|
|
||||||
const profilePicture2 = document.getElementById("preview1") as HTMLImageElement;
|
|
||||||
profilePicture.src = url;
|
|
||||||
profilePicture2.src = url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes profile upload functionality
|
|
||||||
* @function initializeProfileUpload
|
|
||||||
* @example
|
|
||||||
* initializeProfileUpload();
|
|
||||||
*/
|
|
||||||
export function initializeProfileUpload(): void {
|
|
||||||
setupEventListeners();
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Settings dialog management and panel navigation
|
|
||||||
* @description Handles settings dialog functionality and dynamic panel switching
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Dialog } from "mdui/components/dialog";
|
|
||||||
|
|
||||||
const dialog = document.getElementById('settings-dialog') as Dialog;
|
|
||||||
const openButton = document.getElementById('settings-open')!;
|
|
||||||
const closeButton = document.getElementById('settings-close')!;
|
|
||||||
|
|
||||||
// Settings panel management
|
|
||||||
const settingsList = document.querySelector('#settings-menu mdui-list')!;
|
|
||||||
const settingsPanels = document.querySelectorAll('.settings-panel');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mapping between list item text and their corresponding panel IDs
|
|
||||||
* @type {Object.<string, string>}
|
|
||||||
*/
|
|
||||||
const panelMapping = {
|
|
||||||
'Уведомления': 'notifications-settings',
|
|
||||||
'Внешний вид': 'appearance-settings',
|
|
||||||
'Безопасность': 'security-settings',
|
|
||||||
'Язык': 'language-settings',
|
|
||||||
'Хранилище': 'storage-settings',
|
|
||||||
'Помощь': 'help-settings',
|
|
||||||
'О приложении': 'about-settings'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles click events on settings list items
|
|
||||||
* @param {Element} item - The clicked list item element
|
|
||||||
* @function handleListItemClick
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function handleListItemClick(item: Element): void {
|
|
||||||
// Remove active class from all items and panels
|
|
||||||
const listItems = settingsList.querySelectorAll('mdui-list-item');
|
|
||||||
listItems.forEach(li => li.removeAttribute('active'));
|
|
||||||
settingsPanels.forEach(panel => panel.classList.remove('active'));
|
|
||||||
|
|
||||||
// Add active class to clicked item
|
|
||||||
item.setAttribute('active', '');
|
|
||||||
|
|
||||||
// Show corresponding panel using the mapping
|
|
||||||
const itemText = item.textContent?.trim();
|
|
||||||
const panelId = panelMapping[itemText as keyof typeof panelMapping];
|
|
||||||
|
|
||||||
if (panelId) {
|
|
||||||
const targetPanel = document.getElementById(panelId);
|
|
||||||
if (targetPanel) {
|
|
||||||
targetPanel.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up click listeners for all settings list items
|
|
||||||
* @function setupSettingsNavigation
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function setupSettingsNavigation(): void {
|
|
||||||
const listItems = settingsList.querySelectorAll('mdui-list-item');
|
|
||||||
listItems.forEach((item) => {
|
|
||||||
item.addEventListener('click', () => handleListItemClick(item));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets settings dialog to show the first panel
|
|
||||||
* @function resetToFirstPanel
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function resetToFirstPanel(): void {
|
|
||||||
const firstItem = settingsList.querySelector('mdui-list-item');
|
|
||||||
const firstPanel = document.querySelector('.settings-panel');
|
|
||||||
if (firstItem && firstPanel) {
|
|
||||||
settingsList.querySelectorAll('mdui-list-item').forEach(li => li.removeAttribute('active'));
|
|
||||||
settingsPanels.forEach(panel => panel.classList.remove('active'));
|
|
||||||
firstItem.setAttribute('active', '');
|
|
||||||
firstPanel.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up dialog event listeners
|
|
||||||
* @function setupDialogListeners
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function setupDialogListeners(): void {
|
|
||||||
openButton.addEventListener('click', () => {
|
|
||||||
dialog.open = true;
|
|
||||||
resetToFirstPanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
closeButton.addEventListener('click', () => {
|
|
||||||
dialog.open = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setupSettingsNavigation();
|
|
||||||
setupDialogListeners();
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Global TypeScript type definitions
|
|
||||||
* @description Contains all type definitions used throughout the application
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP headers object type
|
|
||||||
* @typedef {Object.<string, string>} Headers
|
|
||||||
*/
|
|
||||||
export type Headers = {[x: string]: string}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API error response structure
|
|
||||||
* @interface ErrorResponse
|
|
||||||
* @property {string} message - Error message from the server
|
|
||||||
*/
|
|
||||||
export interface ErrorResponse {
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 2D coordinate structure
|
|
||||||
* @interface Size2D
|
|
||||||
* @property {number} x - X coordinate
|
|
||||||
* @property {number} y - Y coordinate
|
|
||||||
*/
|
|
||||||
export interface Size2D {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// App types
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chat message structure
|
|
||||||
* @interface Message
|
|
||||||
* @property {number} id - Unique message identifier
|
|
||||||
* @property {string} username - Username of the message sender
|
|
||||||
* @property {string} content - Message content
|
|
||||||
* @property {boolean} is_read - Whether the message has been read
|
|
||||||
* @property {boolean} is_edited - Whether the message has been edited
|
|
||||||
* @property {string} timestamp - ISO timestamp of the message
|
|
||||||
* @property {string} [profile_picture] - URL to sender's profile picture
|
|
||||||
* @property {Message} [reply_to] - The message this is replying to
|
|
||||||
*/
|
|
||||||
export interface Message {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
content: string;
|
|
||||||
is_read: boolean;
|
|
||||||
is_edited: boolean;
|
|
||||||
timestamp: string;
|
|
||||||
profile_picture?: string;
|
|
||||||
reply_to?: Message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collection of messages
|
|
||||||
* @interface Messages
|
|
||||||
* @property {Message[]} messages - Array of message objects
|
|
||||||
*/
|
|
||||||
export interface Messages {
|
|
||||||
messages: Message[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User information structure
|
|
||||||
* @interface User
|
|
||||||
* @property {number} id - Unique user identifier
|
|
||||||
* @property {string} created_at - ISO timestamp of account creation
|
|
||||||
* @property {string} last_seen - ISO timestamp of last activity
|
|
||||||
* @property {boolean} online - Whether the user is currently online
|
|
||||||
* @property {string} username - Username
|
|
||||||
* @property {string} [bio] - User biography
|
|
||||||
*/
|
|
||||||
export interface User {
|
|
||||||
id: number;
|
|
||||||
created_at: string;
|
|
||||||
last_seen: string;
|
|
||||||
online: boolean;
|
|
||||||
username: string;
|
|
||||||
admin?: boolean;
|
|
||||||
bio?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User profile response structure
|
|
||||||
* @interface UserProfile
|
|
||||||
* @property {number} id - Unique user identifier
|
|
||||||
* @property {string} username - Username
|
|
||||||
* @property {string} [profile_picture] - URL to user's profile picture
|
|
||||||
* @property {string} [bio] - User biography
|
|
||||||
* @property {boolean} online - Whether the user is currently online
|
|
||||||
* @property {string} last_seen - ISO timestamp of last activity
|
|
||||||
* @property {string} created_at - ISO timestamp of account creation
|
|
||||||
*/
|
|
||||||
export interface UserProfile {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
profile_picture?: string;
|
|
||||||
bio?: string;
|
|
||||||
online: boolean;
|
|
||||||
last_seen: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------
|
|
||||||
// API models
|
|
||||||
// ----------
|
|
||||||
|
|
||||||
// Requests
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Login request structure
|
|
||||||
* @interface LoginRequest
|
|
||||||
* @property {string} username - Username for authentication
|
|
||||||
* @property {string} password - Password for authentication
|
|
||||||
*/
|
|
||||||
export interface LoginRequest {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registration request structure
|
|
||||||
* @interface RegisterRequest
|
|
||||||
* @property {string} username - Desired username
|
|
||||||
* @property {string} password - Desired password
|
|
||||||
* @property {string} confirm_password - Password confirmation
|
|
||||||
*/
|
|
||||||
export interface RegisterRequest {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
confirm_password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responses
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Login response structure
|
|
||||||
* @interface LoginResponse
|
|
||||||
* @property {User} user - User information
|
|
||||||
* @property {string} token - JWT authentication token
|
|
||||||
*/
|
|
||||||
export interface LoginResponse {
|
|
||||||
user: User;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------
|
|
||||||
// WebSocket types
|
|
||||||
// ---------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket message structure
|
|
||||||
* @interface WebSocketMessage
|
|
||||||
* @property {string} type - Message type identifier
|
|
||||||
* @property {WebSocketCredentials} [credentials] - Authentication credentials
|
|
||||||
* @property {any} [data] - Message payload data
|
|
||||||
* @property {WebSocketError} [error] - Error information if applicable
|
|
||||||
*/
|
|
||||||
export interface WebSocketMessage {
|
|
||||||
type: string;
|
|
||||||
credentials?: WebSocketCredentials;
|
|
||||||
data?: any;
|
|
||||||
error?: WebSocketError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket error structure
|
|
||||||
* @interface WebSocketError
|
|
||||||
* @property {number} code - Error code
|
|
||||||
* @property {string} detail - Error detail message
|
|
||||||
*/
|
|
||||||
export interface WebSocketError {
|
|
||||||
code: number;
|
|
||||||
detail: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket authentication credentials
|
|
||||||
* @interface WebSocketCredentials
|
|
||||||
* @property {string} scheme - Authentication scheme (e.g., "Bearer")
|
|
||||||
* @property {string} credentials - Authentication token or credentials
|
|
||||||
*/
|
|
||||||
export interface WebSocketCredentials {
|
|
||||||
scheme: string;
|
|
||||||
credentials: string;
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview User profile dialog functionality
|
|
||||||
* @description Handles displaying user profiles in a modal dialog
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getAuthHeaders, currentUser } from "./auth";
|
|
||||||
import { API_BASE_URL } from "./config";
|
|
||||||
import type { UserProfile } from "./types";
|
|
||||||
import { showError, showSuccess } from "./utils/notification";
|
|
||||||
import { formatTime } from "./utils/utils";
|
|
||||||
import defaultAvatar from "./images/default-avatar.png";
|
|
||||||
|
|
||||||
|
|
||||||
let dialog = document.getElementById("user-profile-dialog")!;
|
|
||||||
let currentProfile: UserProfile | null = null;
|
|
||||||
let isOwnProfile: boolean = false;
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
bindEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds event listeners
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function bindEvents(): void {
|
|
||||||
// Edit bio events
|
|
||||||
const editBioBtn = dialog?.querySelector('#edit-bio-btn');
|
|
||||||
const saveBioBtn = dialog?.querySelector('#save-bio-btn');
|
|
||||||
const cancelBioBtn = dialog?.querySelector('#cancel-bio-btn');
|
|
||||||
|
|
||||||
editBioBtn?.addEventListener('click', () => startEditBio());
|
|
||||||
saveBioBtn?.addEventListener('click', () => saveBio());
|
|
||||||
cancelBioBtn?.addEventListener('click', () => cancelEditBio());
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && dialog?.getAttribute('open') !== null) {
|
|
||||||
hide();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the profile dialog for a specific user
|
|
||||||
* @param {string} username - Username to show profile for
|
|
||||||
*/
|
|
||||||
export async function show(username: string): Promise<void> {
|
|
||||||
if (!dialog) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to load user profile');
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile: UserProfile = await response.json();
|
|
||||||
currentProfile = profile;
|
|
||||||
isOwnProfile = profile.username === currentUser?.username;
|
|
||||||
populateDialog(profile);
|
|
||||||
(dialog as any).open = true;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
showError('Failed to load user profile');
|
|
||||||
console.error('Error loading user profile:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Populates the dialog with user data
|
|
||||||
* @param {UserProfile} profile - User profile data
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function populateDialog(profile: UserProfile): void {
|
|
||||||
if (!dialog) return;
|
|
||||||
|
|
||||||
// Profile picture
|
|
||||||
const profilePic = dialog.querySelector('.profile-picture') as HTMLImageElement;
|
|
||||||
profilePic.src = profile.profile_picture || defaultAvatar;
|
|
||||||
|
|
||||||
let errorLock = false
|
|
||||||
|
|
||||||
profilePic.addEventListener("error", () => {
|
|
||||||
if (!errorLock) {
|
|
||||||
profilePic.src = defaultAvatar;
|
|
||||||
errorLock = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Username
|
|
||||||
const usernameEl = dialog.querySelector('.username') as HTMLElement;
|
|
||||||
usernameEl.textContent = profile.username;
|
|
||||||
|
|
||||||
// Online status
|
|
||||||
const onlineStatus = dialog.querySelector('.online-status') as HTMLElement;
|
|
||||||
if (profile.online) {
|
|
||||||
onlineStatus.innerHTML = '<span class="online-indicator"></span> Online';
|
|
||||||
onlineStatus.classList.add("online-status", "online");
|
|
||||||
} else {
|
|
||||||
onlineStatus.innerHTML = `<span class="offline-indicator"></span> Last seen ${formatTime(profile.last_seen)}`;
|
|
||||||
onlineStatus.classList.add("online-status", "offline");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bio
|
|
||||||
const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
|
|
||||||
const bioEdit = dialog.querySelector('#bio-edit-field') as any;
|
|
||||||
|
|
||||||
if (profile.bio) {
|
|
||||||
bioDisplay.textContent = profile.bio;
|
|
||||||
} else {
|
|
||||||
bioDisplay.textContent = isOwnProfile ? 'No bio yet. Click "Edit Bio" to add one!' : 'No bio available.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bioEdit) {
|
|
||||||
bioEdit.value = profile.bio || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stats
|
|
||||||
const memberSince = dialog.querySelector('.member-since') as HTMLElement;
|
|
||||||
const lastSeen = dialog.querySelector('.last-seen') as HTMLElement;
|
|
||||||
|
|
||||||
memberSince.textContent = formatTime(profile.created_at);
|
|
||||||
lastSeen.textContent = formatTime(profile.last_seen);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts editing the bio
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function startEditBio(): void {
|
|
||||||
if (!dialog) return;
|
|
||||||
|
|
||||||
const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
|
|
||||||
const bioEdit = dialog.querySelector('#bio-edit-field') as any;
|
|
||||||
const bioActions = dialog.querySelector('.bio-actions') as HTMLElement;
|
|
||||||
const editBioBtn = dialog.querySelector('#edit-bio-btn') as HTMLElement;
|
|
||||||
|
|
||||||
bioDisplay.style.display = 'none';
|
|
||||||
if (bioEdit) bioEdit.style.display = 'block';
|
|
||||||
bioActions.style.display = 'flex';
|
|
||||||
editBioBtn.style.display = 'none';
|
|
||||||
|
|
||||||
if (bioEdit) {
|
|
||||||
bioEdit.focus();
|
|
||||||
bioEdit.setSelectionRange(bioEdit.value.length, bioEdit.value.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the bio
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
export async function saveBio(): Promise<void> {
|
|
||||||
if (!dialog || !currentProfile) return;
|
|
||||||
|
|
||||||
const bioEdit = dialog.querySelector('#bio-edit-field') as any;
|
|
||||||
const newBio = bioEdit?.value?.trim() || '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/user/bio`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
...getAuthHeaders(),
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ bio: newBio })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to update bio');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
currentProfile.bio = result.bio;
|
|
||||||
populateDialog(currentProfile);
|
|
||||||
cancelEditBio();
|
|
||||||
showSuccess('Bio updated successfully');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
showError('Failed to update bio');
|
|
||||||
console.error('Error updating bio:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancels bio editing
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function cancelEditBio(): void {
|
|
||||||
if (!dialog) return;
|
|
||||||
|
|
||||||
const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
|
|
||||||
const bioEdit = dialog.querySelector('#bio-edit-field') as any;
|
|
||||||
const bioActions = dialog.querySelector('.bio-actions') as HTMLElement;
|
|
||||||
const editBioBtn = dialog.querySelector('#edit-bio-btn') as HTMLElement;
|
|
||||||
|
|
||||||
bioDisplay.style.display = 'block';
|
|
||||||
if (bioEdit) bioEdit.style.display = 'none';
|
|
||||||
bioActions.style.display = 'none';
|
|
||||||
editBioBtn.style.display = isOwnProfile ? 'block' : 'none';
|
|
||||||
|
|
||||||
// Reset bio edit to current value
|
|
||||||
if (bioEdit) {
|
|
||||||
bioEdit.value = currentProfile?.bio || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hides the dialog
|
|
||||||
*/
|
|
||||||
export function hide(): void {
|
|
||||||
if (dialog) {
|
|
||||||
(dialog as any).open = false;
|
|
||||||
}
|
|
||||||
currentProfile = null;
|
|
||||||
isOwnProfile = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview MDUI component imports and configuration
|
|
||||||
* @description Imports all required MDUI components and sets up the theme
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import 'mdui/components/tabs';
|
|
||||||
import 'mdui/components/tab';
|
|
||||||
import 'mdui/components/tab-panel';
|
|
||||||
import 'mdui/components/list';
|
|
||||||
import 'mdui/components/list-item';
|
|
||||||
import 'mdui/components/bottom-app-bar';
|
|
||||||
import 'mdui/components/button-icon';
|
|
||||||
import 'mdui/components/fab';
|
|
||||||
import 'mdui/components/dialog';
|
|
||||||
import 'mdui/components/button';
|
|
||||||
import 'mdui/components/text-field';
|
|
||||||
import 'mdui/components/button-icon';
|
|
||||||
import 'mdui/components/top-app-bar';
|
|
||||||
import 'mdui/components/top-app-bar-title';
|
|
||||||
import 'mdui/components/dropdown.js';
|
|
||||||
import 'mdui/components/menu.js';
|
|
||||||
import 'mdui/components/menu-item.js';
|
|
||||||
|
|
||||||
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
|
|
||||||
|
|
||||||
setColorScheme("#91cef4");
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Utility functions used throughout the application
|
|
||||||
* @description Contains helper functions for common operations
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a timestamp string to HH:MM format
|
|
||||||
* @param {string} dateString - ISO timestamp string to format
|
|
||||||
* @returns {string} Formatted time string in HH:MM format
|
|
||||||
* @example
|
|
||||||
* formatTime('2024-01-15T14:30:00Z'); // Returns "14:30"
|
|
||||||
*/
|
|
||||||
export function formatTime(dateString: string): string {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
let hours = date.getHours();
|
|
||||||
let minutes = date.getMinutes();
|
|
||||||
const hoursString = hours < 10 ? '0' + hours : hours;
|
|
||||||
const minutesString = minutes < 10 ? '0' + minutes : minutes;
|
|
||||||
return hoursString + ':' + minutesString;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a promise that resolves after a specified delay
|
|
||||||
* @param {number} ms - Delay time in milliseconds
|
|
||||||
* @returns {Promise<void>} Promise that resolves after the delay
|
|
||||||
* @example
|
|
||||||
* await delay(1000); // Wait for 1 second
|
|
||||||
*/
|
|
||||||
export function delay(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview WebSocket connection management for real-time chat
|
|
||||||
* @description Handles WebSocket connections, message processing, and auto-reconnection
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { handleWebSocketMessage } from "./chat";
|
|
||||||
import { API_FULL_BASE_URL } from "./config";
|
|
||||||
import type { WebSocketMessage } from "./types";
|
|
||||||
import { delay } from "./utils/utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new WebSocket connection to the chat server
|
|
||||||
* @function create
|
|
||||||
* @returns {WebSocket} New WebSocket instance
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function create(): WebSocket {
|
|
||||||
let prefix = "ws://";
|
|
||||||
if (location.protocol.includes("https")) {
|
|
||||||
prefix = "wss://";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new WebSocket(`${prefix}${API_FULL_BASE_URL}/chat/ws`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global WebSocket instance
|
|
||||||
* @type {WebSocket}
|
|
||||||
*/
|
|
||||||
export let websocket: WebSocket = create();
|
|
||||||
|
|
||||||
// --------------
|
|
||||||
// Initialization
|
|
||||||
// --------------
|
|
||||||
|
|
||||||
websocket.addEventListener("message", (e) => {
|
|
||||||
const message: WebSocketMessage = JSON.parse(e.data);
|
|
||||||
handleWebSocketMessage(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
websocket.addEventListener("error", async () => {
|
|
||||||
console.warn("WebSocket disconnected, retrying in 3 seconds...");
|
|
||||||
await delay(3000);
|
|
||||||
websocket = create();
|
|
||||||
|
|
||||||
let listener: () => void | null;
|
|
||||||
listener = () => {
|
|
||||||
console.log("WebSocket successfully reconnected!");
|
|
||||||
websocket.removeEventListener("open", listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
websocket.addEventListener("open", listener);
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { defineConfig, PluginOption } from 'vite';
|
|
||||||
import { createHtmlPlugin } from 'vite-plugin-html';
|
|
||||||
import autoprefixer from 'autoprefixer';
|
|
||||||
import electron from 'vite-plugin-electron/simple';
|
|
||||||
|
|
||||||
const plugins: PluginOption[] = [
|
|
||||||
createHtmlPlugin({
|
|
||||||
minify: {
|
|
||||||
collapseWhitespace: true,
|
|
||||||
removeComments: true,
|
|
||||||
removeRedundantAttributes: true,
|
|
||||||
removeScriptTypeAttributes: true,
|
|
||||||
removeStyleLinkTypeAttributes: true,
|
|
||||||
useShortDoctype: true,
|
|
||||||
minifyCSS: true,
|
|
||||||
minifyJS: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]
|
|
||||||
|
|
||||||
if (process.env.VITE_ELECTRON) {
|
|
||||||
plugins.push(
|
|
||||||
electron({
|
|
||||||
main: {
|
|
||||||
entry: "electron/main.ts",
|
|
||||||
},
|
|
||||||
preload: {
|
|
||||||
input: "frontend/electron/preload.ts"
|
|
||||||
},
|
|
||||||
renderer: {},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: plugins,
|
|
||||||
server: {
|
|
||||||
host: '0.0.0.0',
|
|
||||||
port: 8301,
|
|
||||||
strictPort: true,
|
|
||||||
proxy: {
|
|
||||||
"/api": {
|
|
||||||
target: "http://127.0.0.1:8300/",
|
|
||||||
changeOrigin: true,
|
|
||||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
|
||||||
ws: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
appType: "mpa",
|
|
||||||
css: {
|
|
||||||
postcss: {
|
|
||||||
plugins: [autoprefixer()],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
minify: 'terser',
|
|
||||||
terserOptions: {
|
|
||||||
compress: {
|
|
||||||
drop_console: true,
|
|
||||||
drop_debugger: true
|
|
||||||
},
|
|
||||||
format: {
|
|
||||||
comments: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cssMinify: true,
|
|
||||||
assetsInlineLimit: 0
|
|
||||||
}
|
|
||||||
});
|
|
||||||
+40
-31
@@ -1,46 +1,55 @@
|
|||||||
{
|
{
|
||||||
"name": "pixelchat",
|
"name": "fromchat",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "frontend/dist-electron/main.js",
|
"description": "A 100% Open Source Messenger",
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"authors": "denis0001-dev",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"backend:run": "cd backend && dotenv -e ../deployment/.env -- ../.venv/bin/fastapi dev --port 8300 main.py",
|
"frontend:dev": "vite",
|
||||||
"backend:dependencies": "python3 -m venv .venv && ./.venv/bin/pip3 install -r backend/requirements.txt",
|
"frontend:typecheck": "tsc --project tsconfig.json",
|
||||||
"backend:reinstall": "rm -rf .venv && npm run backend:dependencies",
|
"frontend:build": "npm run frontend:typecheck && vite build",
|
||||||
"backend:clean": "rm -rf backend/data",
|
"frontend:preview": "vite preview",
|
||||||
"frontend:dev": "vite frontend",
|
|
||||||
"frontend:build": "tsc --project frontend && vite build frontend",
|
|
||||||
"frontend:electron:dev": "VITE_ELECTRON=true npm run frontend:dev",
|
|
||||||
"frontend:electron:dependencies": "cd frontend/electron/forge && npm install",
|
|
||||||
"frontend:electron:build": "VITE_ELECTRON=true npm run frontend:build && cp -r frontend/dist frontend/dist-electron frontend/electron/forge && cd frontend/electron/forge && npm run make",
|
|
||||||
"frontend:preview": "vite preview frontend",
|
|
||||||
"frontend:dependencies": "npm install --ignore-scripts",
|
"frontend:dependencies": "npm install --ignore-scripts",
|
||||||
"frontend:clean": "rm -rf frontend/dist",
|
"frontend:clean": "rm -rf build",
|
||||||
"dev": "concurrently 'npm run frontend:dev' 'npm run backend:run'",
|
"build": "npm run frontend:build",
|
||||||
"dev:electron": "concurrently 'npm run frontend:electron:dev' 'npm run backend:run'",
|
"preview": "docker compose -f docker-compose.yml up --build --watch",
|
||||||
"build:electron": "npm run frontend:electron:build",
|
"preview:clean": "docker compose -f docker-compose.yml down -v --remove-orphans",
|
||||||
"build": "npm run frontend:build && npm run build:electron",
|
"prepare": "husky",
|
||||||
"preview": "cd deployment && docker compose up --build --watch",
|
"install": "if [ ! -e .env ]; then cp .env.example .env; fi"
|
||||||
"preview:clean": "cd deployment && docker compose down -v",
|
|
||||||
"clean": "npm run backend:clean && npm run frontend:clean && npm run preview:clean",
|
|
||||||
"install": "npm run backend:dependencies && npm run frontend:electron:dependencies && cp deployment/.env.example deployment/.env"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/he": "^1.2.3",
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
|
"@types/react": "^19.1.13",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"concurrently": "^9.2.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"electron": "^37.3.1",
|
"husky": "^9.1.7",
|
||||||
"dotenv-cli": "^10.0.0",
|
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"sass-embedded": "^1.90.0",
|
"rollup-plugin-visualizer": "^6.0.4",
|
||||||
"terser": "^5.43.1",
|
"sass-embedded": "^1.93.0",
|
||||||
|
"svgo": "^4.0.0",
|
||||||
|
"terser": "^5.44.0",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vite": "^7.1.2",
|
"vite": "^8.2.1",
|
||||||
"vite-plugin-electron": "^0.29.0",
|
"vite-plugin-html": "^3.2.2",
|
||||||
"vite-plugin-electron-renderer": "^0.14.6",
|
"vite-plugin-sass-dts": "^1.3.34"
|
||||||
"vite-plugin-html": "^3.2.2"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mdui": "^2.1.4"
|
"escape-string-regexp": "^5.0.0",
|
||||||
|
"he": "^1.2.0",
|
||||||
|
"idb": "^8.0.3",
|
||||||
|
"marked": "^17.0.1",
|
||||||
|
"mdui": "^2.1.4",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-router-dom": "^7.9.3",
|
||||||
|
"tweetnacl": "^1.0.3",
|
||||||
|
"use-immer": "^0.11.0",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Plugin, UserConfig } from 'vite';
|
||||||
|
|
||||||
|
const DEFAULT_DICTIONARY = '_-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
|
||||||
|
function counter(dictionary: string = DEFAULT_DICTIONARY) {
|
||||||
|
const sequence: string[] = [dictionary[0]];
|
||||||
|
return () => {
|
||||||
|
const str = sequence.join('');
|
||||||
|
let carry = 0;
|
||||||
|
for (let i = 0; i < sequence.length; i++) {
|
||||||
|
const index = dictionary.indexOf(sequence[i]) + carry + 1;
|
||||||
|
if (index < dictionary.length) {
|
||||||
|
sequence[i] = dictionary[index];
|
||||||
|
/**
|
||||||
|
* Make sure the following rules are not violated:
|
||||||
|
* 1. The first character cannot be a number
|
||||||
|
* 2. The second character cannot be a number if the first is a dash
|
||||||
|
* 3. The dash cannot be the only character
|
||||||
|
*
|
||||||
|
* https://www.w3.org/TR/CSS21/syndata.html#characters
|
||||||
|
*/
|
||||||
|
const [c1, c2] = sequence;
|
||||||
|
if (
|
||||||
|
(c1 >= '0' && c1 <= '9') ||
|
||||||
|
(c1 === '-' && (c2 >= '0' && c2 <= '9')) ||
|
||||||
|
(c1 === '-' && sequence.length === 1)
|
||||||
|
) {
|
||||||
|
i--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
carry = 0;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
sequence[i] = dictionary[0];
|
||||||
|
carry = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (carry) {
|
||||||
|
sequence.push(dictionary[0]);
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface OptimizeCssModuleOptions {
|
||||||
|
dictionary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optimizeCssModules(options?: OptimizeCssModuleOptions): Plugin {
|
||||||
|
const next = counter(options?.dictionary);
|
||||||
|
const map: Map<string, string> = new Map();
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'optimize-css-modules',
|
||||||
|
apply: 'build',
|
||||||
|
config: () => ({
|
||||||
|
css: {
|
||||||
|
modules: {
|
||||||
|
generateScopedName: (name: string, fileName: string) => {
|
||||||
|
const key = fileName + name;
|
||||||
|
let hash = map.get(key);
|
||||||
|
if (!hash) {
|
||||||
|
map.set(key, (hash = next()));
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Plugin } from 'vite';
|
||||||
|
import { optimize } from 'svgo';
|
||||||
|
|
||||||
|
export interface OptimizeSvgOptions {
|
||||||
|
/**
|
||||||
|
* Whether to enable SVG optimization
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgoConfig: Parameters<typeof optimize>[1] = {
|
||||||
|
multipass: true,
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: 'preset-default',
|
||||||
|
params: {
|
||||||
|
overrides: {
|
||||||
|
// Keep IDs if they might be referenced (minify instead of remove)
|
||||||
|
cleanupIds: {
|
||||||
|
remove: false,
|
||||||
|
minify: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimizes SVG files during build by:
|
||||||
|
* - Minifying SVG code
|
||||||
|
* - Removing metadata and comments
|
||||||
|
* - Removing unnecessary attributes
|
||||||
|
* - Optimizing paths and shapes
|
||||||
|
*/
|
||||||
|
export function optimizeSvg(options?: OptimizeSvgOptions): Plugin {
|
||||||
|
const enabled = options?.enabled !== false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'optimize-svg',
|
||||||
|
apply: 'build',
|
||||||
|
enforce: 'post',
|
||||||
|
async generateBundle(options, bundle) {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
// Optimize SVGs in the bundle
|
||||||
|
for (const [fileName, chunk] of Object.entries(bundle)) {
|
||||||
|
if (fileName.endsWith('.svg') && chunk.type === 'asset') {
|
||||||
|
try {
|
||||||
|
const svgContent = typeof chunk.source === 'string'
|
||||||
|
? chunk.source
|
||||||
|
: Buffer.from(chunk.source).toString('utf-8');
|
||||||
|
|
||||||
|
const result = optimize(svgContent, svgoConfig);
|
||||||
|
|
||||||
|
if (result.data && result.data !== svgContent) {
|
||||||
|
chunk.source = result.data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to optimize SVG ${fileName}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" class="mdui-theme-dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Loading...</title>
|
||||||
|
<link rel="icon" href="./images/logo.svg" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="main/main.tsx" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, useNavigate, useLocation, matchRoutes, Navigate, type RouteObject } from "react-router-dom";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useUserStore } from "./state/user";
|
||||||
|
import { lazy, useEffect, useRef, useState } from "react";
|
||||||
|
import { parseProfileLink } from "./core/profileLinks";
|
||||||
|
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
||||||
|
import ProtectedRoute from "./pages/ProtectedRoute";
|
||||||
|
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
||||||
|
import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog";
|
||||||
|
import { AlertDialogProvider } from "./core/components/AlertDialog";
|
||||||
|
import { delay } from "./utils/utils";
|
||||||
|
|
||||||
|
// Lazy load route components
|
||||||
|
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
||||||
|
const AuthPage = lazy(() => import("./pages/auth/AuthPage"));
|
||||||
|
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
||||||
|
const PrivacyPage = lazy(() => import("./pages/legal/LegalPages").then(m => ({ default: m.PrivacyPage })));
|
||||||
|
const TermsPage = lazy(() => import("./pages/legal/LegalPages").then(m => ({ default: m.TermsPage })));
|
||||||
|
|
||||||
|
const routeConfig: RouteObject[] = [
|
||||||
|
{ path: "/", element: <HomePage /> },
|
||||||
|
{ path: "/auth", element: <AuthPage /> },
|
||||||
|
{ path: "/login", element: <Navigate to="/auth?mode=login" replace /> },
|
||||||
|
{ path: "/register", element: <Navigate to="/auth?mode=register" replace /> },
|
||||||
|
{ path: "/download-app", element: <DownloadAppPage /> },
|
||||||
|
{ path: "/privacy", element: <PrivacyPage /> },
|
||||||
|
{ path: "/terms", element: <TermsPage /> },
|
||||||
|
{
|
||||||
|
path: "/chat",
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ChatPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ path: "*", element: <SmartCatchAll /> }
|
||||||
|
];
|
||||||
|
|
||||||
|
function SmartCatchAll() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [showNotFound, setShowNotFound] = useState(false);
|
||||||
|
|
||||||
|
function isValidRoute(path: string): boolean {
|
||||||
|
const validRoutes = routeConfig.filter(route => route.path !== "*");
|
||||||
|
const matches = matchRoutes(validRoutes, path);
|
||||||
|
|
||||||
|
return Boolean(matches && matches.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isValidRoute(location.pathname)) {
|
||||||
|
setShowNotFound(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileInfo = parseProfileLink(); // No URL specified intentionally to let it use the current URL
|
||||||
|
|
||||||
|
if (profileInfo) {
|
||||||
|
setShowNotFound(false);
|
||||||
|
navigate("/chat", {
|
||||||
|
replace: true,
|
||||||
|
state: { profileInfo }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setShowNotFound(true);
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// Show 404 page
|
||||||
|
if (showNotFound) {
|
||||||
|
return <NotFoundPage />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedRoutes() {
|
||||||
|
const location = useLocation();
|
||||||
|
const prevPathnameRef = useRef(location.pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="sync" initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={location.pathname}
|
||||||
|
onAnimationStart={() => {
|
||||||
|
if (prevPathnameRef.current !== location.pathname) {
|
||||||
|
prevPathnameRef.current = location.pathname;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onAnimationComplete={async () => {
|
||||||
|
await delay(500);
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 1, scale: 1.1 }}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30,
|
||||||
|
mass: 0.8
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
transformOrigin: "center center",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Routes location={location}>
|
||||||
|
{routeConfig.map((route, index) => (
|
||||||
|
<Route key={index} path={route.path} element={route.element} />
|
||||||
|
))}
|
||||||
|
</Routes>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { restoreFromStorage, user } = useUserStore();
|
||||||
|
const [authReady, setAuthReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restoreFromStorage().finally(() => {
|
||||||
|
setAuthReady(true);
|
||||||
|
});
|
||||||
|
}, [restoreFromStorage]);
|
||||||
|
|
||||||
|
return authReady && (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AlertDialogProvider />
|
||||||
|
<div id="main-wrapper">
|
||||||
|
<AnimatedRoutes />
|
||||||
|
</div>
|
||||||
|
{user.isSuspended && (
|
||||||
|
<SuspensionDialog
|
||||||
|
reason={user.suspensionReason || "No reason provided"}
|
||||||
|
open={true}
|
||||||
|
onOpenChange={() => {}} // Suspended users can't close the dialog
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { MaterialIcon } from "@/utils/material";
|
||||||
|
import { avatarGradientFromUserId } from "@/core/avatarGradient";
|
||||||
|
import styles from "@/pages/chat/css/deleted-user-avatar.module.scss";
|
||||||
|
|
||||||
|
interface DeletedUserAvatarProps {
|
||||||
|
userId: number;
|
||||||
|
className?: string;
|
||||||
|
iconClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeletedUserAvatar({ userId, className, iconClassName }: DeletedUserAvatarProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className ?? styles.deletedUserAvatar}
|
||||||
|
style={{ background: avatarGradientFromUserId(userId) }}
|
||||||
|
>
|
||||||
|
<MaterialIcon
|
||||||
|
name="account_circle_off--outlined"
|
||||||
|
className={iconClassName ?? styles.deletedUserAvatarIcon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { API_BASE_URL } from "@/core/config";
|
||||||
|
import { getAuthHeaders } from "./index";
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
session_id: string;
|
||||||
|
device_name?: string;
|
||||||
|
device_type?: string;
|
||||||
|
os_name?: string;
|
||||||
|
os_version?: string;
|
||||||
|
browser_name?: string;
|
||||||
|
browser_version?: string;
|
||||||
|
brand?: string;
|
||||||
|
model?: string;
|
||||||
|
created_at?: string;
|
||||||
|
last_seen?: string;
|
||||||
|
revoked?: boolean;
|
||||||
|
current?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDevices(token: string): Promise<DeviceInfo[]> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) });
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch devices");
|
||||||
|
const data = await res.json();
|
||||||
|
return data.devices as DeviceInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeDevice(token: string, sessionId: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) });
|
||||||
|
if (!res.ok) throw new Error("Failed to revoke device");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logoutAllOtherDevices(token: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) });
|
||||||
|
if (!res.ok) throw new Error("Failed to logout all devices");
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { API_BASE_URL } from "@/core/config";
|
||||||
|
import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types";
|
||||||
|
import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
|
||||||
|
import { b64, ub64 } from "@/utils/utils";
|
||||||
|
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
|
||||||
|
import type { Headers } from "@/core/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates authentication headers for API requests
|
||||||
|
* @param {string | null} token - Authentication token
|
||||||
|
* @param {boolean} json - Whether to include JSON content type header
|
||||||
|
* @returns {Headers} Headers object with authentication and content type
|
||||||
|
*/
|
||||||
|
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
|
||||||
|
const headers: Headers = {};
|
||||||
|
|
||||||
|
if (json) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckAuthResponse {
|
||||||
|
authenticated: boolean;
|
||||||
|
username: string;
|
||||||
|
admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogoutResponse {
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserKeyPairMemory {
|
||||||
|
publicKey: Uint8Array;
|
||||||
|
privateKey: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPublicKey: Uint8Array | null = null;
|
||||||
|
let currentPrivateKey: Uint8Array | null = null;
|
||||||
|
|
||||||
|
export function getCurrentKeys(): UserKeyPairMemory | null {
|
||||||
|
if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveKeys(
|
||||||
|
publicKey: Uint8Array<ArrayBufferLike>,
|
||||||
|
privateKey: Uint8Array<ArrayBufferLike>
|
||||||
|
) {
|
||||||
|
const encodedPublicKey = b64(publicKey);
|
||||||
|
const encodedPrivateKey = b64(privateKey);
|
||||||
|
|
||||||
|
localStorage.setItem("publicKey", encodedPublicKey);
|
||||||
|
localStorage.setItem("privateKey", encodedPrivateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the current user is authenticated
|
||||||
|
*/
|
||||||
|
export async function checkAuth(token: string): Promise<CheckAuthResponse> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/check_auth`, {
|
||||||
|
headers: getAuthHeaders(token, true)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to check auth");
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs in a user with username and password (step-based auth).
|
||||||
|
*/
|
||||||
|
export async function login(request: LoginRequest): Promise<LoginResponse> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/auth/steps/password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: getAuthHeaders(null, true),
|
||||||
|
body: JSON.stringify(request)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({ detail: "Login failed" }));
|
||||||
|
throw new Error(error.detail || "Login failed");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === "needs_register") {
|
||||||
|
throw new Error("Account not found");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a new user (step-based auth; Yandex proof required when enabled on server).
|
||||||
|
*/
|
||||||
|
export async function register(request: RegisterRequest & { registration_proof?: string }): Promise<LoginResponse> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/auth/steps/register/confirm`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: getAuthHeaders(null, true),
|
||||||
|
body: JSON.stringify({
|
||||||
|
display_name: request.display_name,
|
||||||
|
username: request.username,
|
||||||
|
password: request.password,
|
||||||
|
confirm_password: request.confirm_password,
|
||||||
|
bio: request.bio,
|
||||||
|
registration_proof: request.registration_proof,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({ detail: "Registration failed" }));
|
||||||
|
throw new Error(error.detail || "Registration failed");
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs out the current user
|
||||||
|
*/
|
||||||
|
export async function logout(token: string): Promise<LogoutResponse> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/logout`, {
|
||||||
|
headers: getAuthHeaders(token, true)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to logout");
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a client-side authentication secret so the raw password never leaves the client.
|
||||||
|
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
|
||||||
|
*/
|
||||||
|
export async function deriveAuthSecret(username: string, password: string): Promise<string> {
|
||||||
|
// Use per-user salt derived from username; in future we can fetch a server-provided salt
|
||||||
|
const salt = new TextEncoder().encode(`fromchat.user:${username}`);
|
||||||
|
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
|
||||||
|
const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32);
|
||||||
|
return b64(derived);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
||||||
|
// Try to restore from backup
|
||||||
|
const blobJson = await fetchBackupBlob(token);
|
||||||
|
if (blobJson) {
|
||||||
|
const blob = decodeBlob(blobJson);
|
||||||
|
const bundle = await decryptBackupWithPassword(password, blob);
|
||||||
|
currentPrivateKey = bundle.privateKey;
|
||||||
|
// Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous
|
||||||
|
// In our simple scheme, we rely on server having the public key or we reupload generated one on first setup
|
||||||
|
const serverPub = await fetchPublicKey(token);
|
||||||
|
if (serverPub) {
|
||||||
|
currentPublicKey = serverPub;
|
||||||
|
} else {
|
||||||
|
// We don't have the corresponding public key from server; regenerate pair to resync
|
||||||
|
const pair = generateX25519KeyPair();
|
||||||
|
currentPublicKey = pair.publicKey;
|
||||||
|
currentPrivateKey = pair.privateKey;
|
||||||
|
await uploadPublicKey(pair.publicKey, token);
|
||||||
|
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||||
|
await uploadBackupBlob(encodeBlob(newBlob), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveKeys(currentPublicKey!, currentPrivateKey!);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: currentPublicKey!,
|
||||||
|
privateKey: currentPrivateKey!
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-time setup: generate keys and upload
|
||||||
|
const pair = generateX25519KeyPair();
|
||||||
|
currentPublicKey = pair.publicKey;
|
||||||
|
currentPrivateKey = pair.privateKey;
|
||||||
|
await uploadPublicKey(pair.publicKey, token);
|
||||||
|
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||||
|
await uploadBackupBlob(encodeBlob(encBlob), token);
|
||||||
|
|
||||||
|
saveKeys(pair.publicKey, pair.privateKey);
|
||||||
|
|
||||||
|
return pair;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreKeys() {
|
||||||
|
currentPublicKey = ub64(localStorage.getItem("publicKey")!);
|
||||||
|
currentPrivateKey = ub64(localStorage.getItem("privateKey")!);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthToken(): string | null {
|
||||||
|
return localStorage.getItem("authToken");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the user's password
|
||||||
|
*/
|
||||||
|
export async function changePassword(
|
||||||
|
token: string,
|
||||||
|
username: string,
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
logoutAllExceptCurrent: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const currentDerived = await deriveAuthSecret(username, currentPassword);
|
||||||
|
const newDerived = await deriveAuthSecret(username, newPassword);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/change-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: getAuthHeaders(token, true),
|
||||||
|
body: JSON.stringify({
|
||||||
|
currentPasswordDerived: currentDerived,
|
||||||
|
newPasswordDerived: newDerived,
|
||||||
|
logoutAllExceptCurrent
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to change password");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the current user's account
|
||||||
|
*/
|
||||||
|
export async function deleteAccount(token: string): Promise<{ status: string; message: string }> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/account/delete`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: getAuthHeaders(token, true)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({ detail: "Failed to delete account" }));
|
||||||
|
throw new Error(error.detail || "Failed to delete account");
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { getAuthHeaders } from ".";
|
||||||
|
import { API_BASE_URL } from "@/core/config";
|
||||||
|
import type { UserProfile } from "@/core/types";
|
||||||
|
|
||||||
|
export interface ProfileData {
|
||||||
|
profile_picture?: string;
|
||||||
|
username?: string;
|
||||||
|
display_name?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResponse {
|
||||||
|
profile_picture_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads user profile data from the server
|
||||||
|
*/
|
||||||
|
export async function loadProfile(token: string): Promise<ProfileData | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||||
|
headers: getAuthHeaders(token, true)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
// Map backend fields to frontend fields
|
||||||
|
return {
|
||||||
|
profile_picture: data.profile_picture,
|
||||||
|
username: data.username,
|
||||||
|
display_name: data.display_name,
|
||||||
|
description: data.bio
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading profile:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a profile picture to the server
|
||||||
|
*/
|
||||||
|
export async function uploadProfilePicture(token: string, file: Blob): Promise<UploadResponse | null> {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('profile_picture', file, 'profile_picture.jpg');
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: getAuthHeaders(token, false)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates user profile information
|
||||||
|
*/
|
||||||
|
export async function updateProfile(token: string, data: Partial<ProfileData>): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Map frontend fields to backend fields
|
||||||
|
const backendData = {
|
||||||
|
username: data.username,
|
||||||
|
display_name: data.display_name,
|
||||||
|
description: data.description
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeaders(token, true),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(backendData)
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating profile:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates user bio
|
||||||
|
*/
|
||||||
|
export async function updateBio(token: string, bio: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/bio`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(token, true),
|
||||||
|
body: JSON.stringify({ bio })
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating bio:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches user profile data by username
|
||||||
|
*/
|
||||||
|
export async function fetchUserProfile(token: string, username: string): Promise<UserProfile | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
|
||||||
|
headers: getAuthHeaders(token, true)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user profile:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches user profile data by user ID
|
||||||
|
*/
|
||||||
|
export async function fetchUserProfileById(token: string, userId: number): Promise<UserProfile | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, {
|
||||||
|
headers: getAuthHeaders(token, true)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user profile by ID:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles verification status for a user (owner only)
|
||||||
|
*/
|
||||||
|
export async function verifyUser(userId: number, token: string): Promise<{verified: boolean} | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(token, true)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying user:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Suspends a user account (admin only)
|
||||||
|
*/
|
||||||
|
export async function suspendUser(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(token, true),
|
||||||
|
body: JSON.stringify({ reason })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error suspending user:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsuspends a user account (admin only)
|
||||||
|
*/
|
||||||
|
export async function unsuspendUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(token, true)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error unsuspending user:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a user account (admin only)
|
||||||
|
*/
|
||||||
|
export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(token, true)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting user:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { API_BASE_URL } from "@/core/config";
|
||||||
|
import { getAuthHeaders } from "../user/auth";
|
||||||
|
import { getCurrentKeys } from "../user/auth";
|
||||||
|
import { request } from "@/core/websocket";
|
||||||
|
import type { DmEnvelope, User } from "@/core/types";
|
||||||
|
import { ub64 } from "@/utils/utils";
|
||||||
|
import { fetchUserPublicKey } from "../crypto/identity";
|
||||||
|
import { searchUsers } from "../user/search";
|
||||||
|
import { deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
|
||||||
|
import tweetnacl from "tweetnacl";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwrap a MEK using the appropriate wrapping key for the current user
|
||||||
|
*/
|
||||||
|
export async function unwrapMek(wrappedMekB64: string, envelope: DmEnvelope, userId?: number): Promise<Uint8Array> {
|
||||||
|
const keys = getCurrentKeys();
|
||||||
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
|
|
||||||
|
// Determine context based on whether we're sender or recipient
|
||||||
|
const currentUserId = userId || parseInt(localStorage.getItem('userId') || '0');
|
||||||
|
const isRecipient = envelope.recipientId === currentUserId;
|
||||||
|
const context = isRecipient ? "recipient_wrap_key" : "sender_wrap_key";
|
||||||
|
|
||||||
|
// Derive wrapping key from our public key
|
||||||
|
const salt = new Uint8Array(16).fill(0); // 16 zero bytes salt
|
||||||
|
const wrappingKeyRaw = await deriveWrappingKey(keys.publicKey, salt, new TextEncoder().encode(context));
|
||||||
|
const wrappingKey = await importAesGcmKey(wrappingKeyRaw);
|
||||||
|
|
||||||
|
// Unwrap the MEK using AES-256-GCM
|
||||||
|
const wrappedMekBytes = ub64(wrappedMekB64);
|
||||||
|
const mekNonce = wrappedMekBytes.slice(0, 12);
|
||||||
|
const mekCiphertext = wrappedMekBytes.slice(12);
|
||||||
|
|
||||||
|
return await aesGcmDecrypt(wrappingKey, mekNonce, mekCiphertext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decrypt(envelope: DmEnvelope, userId?: number): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Use the wrapped MEK provided for this user
|
||||||
|
const wrappedMekB64 = envelope.wrapped_mek_b64;
|
||||||
|
if (!wrappedMekB64) throw new Error("No wrapped MEK available for decryption");
|
||||||
|
|
||||||
|
// Unwrap the MEK using shared logic
|
||||||
|
const mek = await unwrapMek(wrappedMekB64, envelope, userId);
|
||||||
|
|
||||||
|
// Decrypt the message using the unwrapped MEK
|
||||||
|
// Server encrypts with AES-GCM, so client decrypts with AES-GCM
|
||||||
|
// envelope.iv_b64 and envelope.ciphertext_b64 are base64-encoded separately
|
||||||
|
const messageKey = await importAesGcmKey(mek);
|
||||||
|
const messageNonce = ub64(envelope.iv_b64 || "");
|
||||||
|
const messageCiphertext = ub64(envelope.ciphertext_b64);
|
||||||
|
|
||||||
|
const plaintext = await aesGcmDecrypt(messageKey, messageNonce, messageCiphertext);
|
||||||
|
const result = new TextDecoder().decode(plaintext);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to decrypt DM envelope:", error);
|
||||||
|
console.error("Error details:", {
|
||||||
|
envelope: envelope,
|
||||||
|
userId: userId,
|
||||||
|
localStorageUserId: localStorage.getItem('userId')
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
||||||
|
let url = `${API_BASE_URL}/dm/history/${userId}?limit=${limit}`;
|
||||||
|
if (beforeId) {
|
||||||
|
url += `&before_id=${beforeId}`;
|
||||||
|
}
|
||||||
|
const response = await globalThis.fetch(url, {
|
||||||
|
headers: getAuthHeaders(token, true)
|
||||||
|
});
|
||||||
|
if (!response.ok) return { messages: [], has_more: false };
|
||||||
|
const data = await response.json();
|
||||||
|
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the transport public key from the server
|
||||||
|
*/
|
||||||
|
async function getTransportPublicKey(): Promise<string> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch transport key: HTTP ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
return data.public_key_b64;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt message using transport key (client-side only)
|
||||||
|
*/
|
||||||
|
function encryptWithTransportKey(plaintext: string, transportPublicKeyB64: string): { client_public_key_b64: string; nonce_b64: string; ciphertext_b64: string } {
|
||||||
|
const plaintextBytes = new TextEncoder().encode(plaintext);
|
||||||
|
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||||
|
const transportPublicKeyBytes = new Uint8Array(
|
||||||
|
atob(transportPublicKeyB64)
|
||||||
|
.split("")
|
||||||
|
.map((c: string) => c.charCodeAt(0))
|
||||||
|
);
|
||||||
|
|
||||||
|
const nonce = tweetnacl.randomBytes(24);
|
||||||
|
const ciphertext = tweetnacl.box(plaintextBytes, nonce, transportPublicKeyBytes, ephemeralKeypair.secretKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
client_public_key_b64: btoa(String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])),
|
||||||
|
nonce_b64: btoa(String.fromCharCode.apply(null, Array.from(nonce) as number[])),
|
||||||
|
ciphertext_b64: btoa(String.fromCharCode.apply(null, Array.from(ciphertext) as number[]))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number, attachments?: Array<{name:string,path:string,wrapped_mek_b64?:string,nonce_b64?:string}>): Promise<void> {
|
||||||
|
// Get keys
|
||||||
|
const keys = getCurrentKeys();
|
||||||
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
|
|
||||||
|
const transportPublicKeyB64 = await getTransportPublicKey();
|
||||||
|
|
||||||
|
// Client-side transport encryption only
|
||||||
|
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
|
||||||
|
|
||||||
|
// Get sender's public key (from current keys)
|
||||||
|
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||||
|
|
||||||
|
// Send to server (server will handle envelope encryption)
|
||||||
|
const bodyPayload: any = {
|
||||||
|
recipient_id: recipientId,
|
||||||
|
client_public_key_b64,
|
||||||
|
transport_nonce_b64: nonce_b64,
|
||||||
|
transport_ciphertext_b64: ciphertext_b64,
|
||||||
|
sender_public_key_b64: senderPublicKeyB64,
|
||||||
|
recipient_public_key_b64: recipientPublicKeyB64,
|
||||||
|
reply_to_id: replyToId
|
||||||
|
};
|
||||||
|
if (attachments && attachments.length > 0) bodyPayload["files"] = attachments;
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...getAuthHeaders(authToken, true)
|
||||||
|
},
|
||||||
|
body: JSON.stringify(bodyPayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function sendWithFiles(
|
||||||
|
recipientId: number,
|
||||||
|
recipientPublicKeyB64: string,
|
||||||
|
files: File[],
|
||||||
|
plaintext: string,
|
||||||
|
authToken: string,
|
||||||
|
replyToId?: number
|
||||||
|
): Promise<void> {
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
throw new Error("No files provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get transport key for encryption (shared across message + files)
|
||||||
|
const transportKeyResponse = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||||
|
if (!transportKeyResponse.ok) {
|
||||||
|
throw new Error("Failed to get transport key");
|
||||||
|
}
|
||||||
|
const transportKeyData = await transportKeyResponse.json();
|
||||||
|
const transportPublicKeyB64 = transportKeyData.public_key_b64;
|
||||||
|
|
||||||
|
const keys = getCurrentKeys();
|
||||||
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
|
|
||||||
|
const transportPublicKey = ub64(transportPublicKeyB64);
|
||||||
|
|
||||||
|
// One ephemeral keypair for message + all files (must match messaging service decrypt_transport_blob).
|
||||||
|
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||||
|
const messagePlaintextBytes = new TextEncoder().encode(plaintext || "");
|
||||||
|
const messageNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
|
||||||
|
const messageCiphertext = tweetnacl.box(
|
||||||
|
messagePlaintextBytes,
|
||||||
|
messageNonce,
|
||||||
|
transportPublicKey,
|
||||||
|
ephemeralKeypair.secretKey
|
||||||
|
);
|
||||||
|
const client_public_key_b64 = btoa(
|
||||||
|
String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])
|
||||||
|
);
|
||||||
|
const nonce_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageNonce) as number[]));
|
||||||
|
const ciphertext_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageCiphertext) as number[]));
|
||||||
|
|
||||||
|
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||||
|
|
||||||
|
// Base64 encode helper (chunked)
|
||||||
|
const uint8ToB64 = (uint8: Uint8Array): string => {
|
||||||
|
const CHUNK = 0x8000;
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < uint8.length; i += CHUNK) {
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(uint8.subarray(i, i + CHUNK)) as number[]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transport-encrypt files; server will envelope-encrypt them with the SAME MEK as the message.
|
||||||
|
const transport_files: Array<{ encrypted_file_data_b64: string; filename: string; file_size: number }> = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const fileData = await file.arrayBuffer();
|
||||||
|
const transportNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
|
||||||
|
const transportEncrypted = tweetnacl.box(
|
||||||
|
new Uint8Array(fileData),
|
||||||
|
transportNonce,
|
||||||
|
transportPublicKey,
|
||||||
|
ephemeralKeypair.secretKey
|
||||||
|
);
|
||||||
|
const transportEncryptedWithNonce = new Uint8Array(transportNonce.length + transportEncrypted.length);
|
||||||
|
transportEncryptedWithNonce.set(transportNonce);
|
||||||
|
transportEncryptedWithNonce.set(transportEncrypted, transportNonce.length);
|
||||||
|
|
||||||
|
transport_files.push({
|
||||||
|
encrypted_file_data_b64: uint8ToB64(transportEncryptedWithNonce),
|
||||||
|
filename: file.name,
|
||||||
|
file_size: file.size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
recipient_id: recipientId,
|
||||||
|
client_public_key_b64,
|
||||||
|
transport_nonce_b64: nonce_b64,
|
||||||
|
transport_ciphertext_b64: ciphertext_b64,
|
||||||
|
sender_public_key_b64: senderPublicKeyB64,
|
||||||
|
recipient_public_key_b64: recipientPublicKeyB64,
|
||||||
|
reply_to_id: replyToId,
|
||||||
|
transport_files
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...getAuthHeaders(authToken, true)
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function deleteMessage(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||||
|
await request({
|
||||||
|
type: "dmDelete",
|
||||||
|
credentials: { scheme: "Bearer", credentials: authToken },
|
||||||
|
data: { id, recipientId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationResponse {
|
||||||
|
user: User;
|
||||||
|
lastMessage: DmEnvelope;
|
||||||
|
unreadCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function conversations(token: string): Promise<ConversationResponse[]> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||||
|
headers: getAuthHeaders(token, true)
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return data.conversations || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a DM as read
|
||||||
|
*/
|
||||||
|
export async function markRead(id: number, authToken: string): Promise<void> {
|
||||||
|
await request({
|
||||||
|
type: "dmMarkRead",
|
||||||
|
credentials: { scheme: "Bearer", credentials: authToken },
|
||||||
|
data: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editMessage(
|
||||||
|
messageId: number,
|
||||||
|
recipientPublicKeyB64: string,
|
||||||
|
plaintext: string,
|
||||||
|
authToken: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Get keys
|
||||||
|
const keys = getCurrentKeys();
|
||||||
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
|
|
||||||
|
// Get transport key for initial encryption
|
||||||
|
const transportPublicKeyB64 = await getTransportPublicKey();
|
||||||
|
|
||||||
|
// Client-side transport encryption (same as sending)
|
||||||
|
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
|
||||||
|
|
||||||
|
// Get sender's public key
|
||||||
|
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||||
|
|
||||||
|
// Send transport-encrypted data to the edit endpoint (it will handle envelope encryption)
|
||||||
|
const editResponse = await fetch(`${API_BASE_URL}/dm/edit/${messageId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...getAuthHeaders(authToken, true)
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_public_key_b64,
|
||||||
|
transport_nonce_b64: nonce_b64,
|
||||||
|
transport_ciphertext_b64: ciphertext_b64,
|
||||||
|
sender_public_key_b64: senderPublicKeyB64,
|
||||||
|
recipient_public_key_b64: recipientPublicKeyB64
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!editResponse.ok) throw new Error(`Failed to edit DM: HTTP ${editResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export user functions for convenience
|
||||||
|
export { searchUsers, fetchUserPublicKey };
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { API_BASE_URL } from "@/core/config";
|
||||||
|
import { getAuthHeaders } from "../user/auth";
|
||||||
|
import type { Message, Messages, SendMessageRequest } from "@/core/types";
|
||||||
|
import { request } from "@/core/websocket";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches public chat messages
|
||||||
|
*/
|
||||||
|
export async function fetchMessages(token: string, limit: number = 50, beforeId?: number): Promise<{ messages: Message[]; has_more: boolean }> {
|
||||||
|
let url = `${API_BASE_URL}/get_messages?limit=${limit}`;
|
||||||
|
if (beforeId) {
|
||||||
|
url += `&before_id=${beforeId}`;
|
||||||
|
}
|
||||||
|
const response = await globalThis.fetch(url, {
|
||||||
|
headers: getAuthHeaders(token, true)
|
||||||
|
});
|
||||||
|
if (!response.ok) return { messages: [], has_more: false };
|
||||||
|
const data: Messages & { has_more?: boolean } = await response.json();
|
||||||
|
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a public chat message via WebSocket
|
||||||
|
*/
|
||||||
|
export async function send(content: string, replyToId: number | null, authToken: string): Promise<void> {
|
||||||
|
await request({
|
||||||
|
data: {
|
||||||
|
content: content.trim(),
|
||||||
|
reply_to_id: replyToId ?? null
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
|
scheme: "Bearer",
|
||||||
|
credentials: authToken
|
||||||
|
},
|
||||||
|
type: "sendMessage"
|
||||||
|
} satisfies SendMessageRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a public chat message with files via HTTP
|
||||||
|
*/
|
||||||
|
export async function sendWithFiles(
|
||||||
|
content: string,
|
||||||
|
replyToId: number | null,
|
||||||
|
files: File[],
|
||||||
|
authToken: string
|
||||||
|
): Promise<void> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("payload", JSON.stringify({
|
||||||
|
content: content.trim(),
|
||||||
|
reply_to_id: replyToId ?? null
|
||||||
|
} satisfies SendMessageRequest["data"]));
|
||||||
|
for (const f of files) form.append("files", f, f.name);
|
||||||
|
const res = await globalThis.fetch(`${API_BASE_URL}/send_message`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: getAuthHeaders(authToken, false),
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.text();
|
||||||
|
throw new Error(error || "Failed to send message with files");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits a public chat message
|
||||||
|
*/
|
||||||
|
export async function edit(messageId: number, newContent: string, authToken: string): Promise<void> {
|
||||||
|
const res = await globalThis.fetch(`${API_BASE_URL}/edit_message/${messageId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: getAuthHeaders(authToken, true),
|
||||||
|
body: JSON.stringify({ content: newContent })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to edit message");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a public chat message
|
||||||
|
*/
|
||||||
|
export async function deleteMessage(messageId: number, authToken: string): Promise<void> {
|
||||||
|
const res = await globalThis.fetch(`${API_BASE_URL}/delete_message/${messageId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: getAuthHeaders(authToken, true)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to delete message");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a message as read
|
||||||
|
*/
|
||||||
|
export async function markRead(messageId: number, authToken: string): Promise<void> {
|
||||||
|
const res = await globalThis.fetch(`${API_BASE_URL}/messages/mark_read`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: getAuthHeaders(authToken, true),
|
||||||
|
body: JSON.stringify({ message_id: messageId })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to mark message as read");
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { API_BASE_URL } from "@/core/config";
|
||||||
|
import { getAuthHeaders } from "./account";
|
||||||
|
import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types";
|
||||||
|
import { b64, ub64 } from "@/utils/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the current user's public key
|
||||||
|
*/
|
||||||
|
export async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data?.publicKey) return null;
|
||||||
|
return ub64(data.publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads the current user's public key
|
||||||
|
*/
|
||||||
|
export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
||||||
|
const payload: UploadPublicKeyRequest = {
|
||||||
|
publicKey: b64(publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to upload public key");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches another user's public key by user ID
|
||||||
|
*/
|
||||||
|
export async function fetchUserPublicKey(userId: number, token: string): Promise<string | null> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return data.publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the current user's backup blob
|
||||||
|
*/
|
||||||
|
export async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||||
|
method: "GET",
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const response: BackupBlob = await res.json();
|
||||||
|
return response.blob;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads the current user's backup blob
|
||||||
|
*/
|
||||||
|
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||||
|
const payload: BackupBlob = { blob: blobJson }
|
||||||
|
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to upload backup blob");
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { API_BASE_URL } from "@/core/config";
|
||||||
|
import type { BackupBlob } from "@/core/types";
|
||||||
|
import api from "@/core/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the current user's backup blob
|
||||||
|
*/
|
||||||
|
export async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||||
|
const headers = api.user.auth.getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||||
|
method: "GET",
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const response: BackupBlob = await res.json();
|
||||||
|
return response.blob;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads the current user's backup blob
|
||||||
|
*/
|
||||||
|
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||||
|
const payload: BackupBlob = { blob: blobJson }
|
||||||
|
|
||||||
|
const headers = api.user.auth.getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to upload backup blob");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { API_BASE_URL } from "@/core/config";
|
||||||
|
import { getAuthHeaders } from "../user/auth";
|
||||||
|
import type { UploadPublicKeyRequest } from "@/core/types";
|
||||||
|
import { b64, ub64 } from "@/utils/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the current user's public key
|
||||||
|
*/
|
||||||
|
export async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data?.publicKey) return null;
|
||||||
|
return ub64(data.publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads the current user's public key
|
||||||
|
*/
|
||||||
|
export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
||||||
|
const payload: UploadPublicKeyRequest = {
|
||||||
|
publicKey: b64(publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to upload public key");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches another user's public key by user ID
|
||||||
|
*/
|
||||||
|
export async function fetchUserPublicKey(userId: number, token: string): Promise<string | null> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return data.publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Placeholder for Signal Protocol pre-key management
|
||||||
|
// Will be implemented when Signal Protocol is added
|
||||||
|
|
||||||
|
export async function upload(_bundle: unknown, _token: string): Promise<void> {
|
||||||
|
// TODO: Implement Signal Protocol pre-key upload
|
||||||
|
throw new Error("Not implemented yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetch(_userId: number, _token: string): Promise<unknown> {
|
||||||
|
// TODO: Implement Signal Protocol pre-key fetch
|
||||||
|
throw new Error("Not implemented yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user