v0.3.0
@@ -1,15 +1,22 @@
|
|||||||
# jama Configuration
|
# jama Configuration
|
||||||
# just another messaging app
|
# just another messaging app
|
||||||
|
|
||||||
|
# Timezone — must match your host timezone (e.g. America/Toronto, Europe/London, Asia/Tokyo)
|
||||||
|
# Run 'timedatectl' on your host to find the correct value
|
||||||
|
TZ=UTC
|
||||||
# Copy this file to .env and customize
|
# Copy this file to .env and customize
|
||||||
|
|
||||||
# Image version to run (set by build.sh, or use 'latest')
|
# Image version to run (set by build.sh, or use 'latest')
|
||||||
JAMA_VERSION=latest
|
JAMA_VERSION=0.3.0
|
||||||
|
|
||||||
# Default admin credentials (used on FIRST RUN only)
|
# Default admin credentials (used on FIRST RUN only)
|
||||||
ADMIN_NAME=Admin User
|
ADMIN_NAME=Admin User
|
||||||
ADMIN_EMAIL=admin@jama.local
|
ADMIN_EMAIL=admin@jama.local
|
||||||
ADMIN_PASS=Admin@1234
|
ADMIN_PASS=Admin@1234
|
||||||
|
|
||||||
|
# Default password for bulk-imported users (when no password is set in CSV)
|
||||||
|
USER_PASS=user@1234
|
||||||
|
|
||||||
# Set to true to reset admin password to ADMIN_PASS on every restart
|
# Set to true to reset admin password to ADMIN_PASS on every restart
|
||||||
# WARNING: Leave false in production - shows a warning on login page when true
|
# WARNING: Leave false in production - shows a warning on login page when true
|
||||||
PW_RESET=false
|
PW_RESET=false
|
||||||
|
|||||||
@@ -24,15 +24,18 @@ LABEL org.opencontainers.image.title="jama" \
|
|||||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||||
org.opencontainers.image.source="https://github.com/yourorg/jama"
|
org.opencontainers.image.source="https://github.com/yourorg/jama"
|
||||||
|
|
||||||
ENV TEAMCHAT_VERSION=${VERSION}
|
ENV JAMA_VERSION=${VERSION}
|
||||||
|
|
||||||
RUN apk add --no-cache sqlite
|
RUN apk add --no-cache sqlite python3 make g++
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY backend/package*.json ./
|
COPY backend/package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
# Remove build tools after compile to keep image lean
|
||||||
|
RUN apk del python3 make g++
|
||||||
|
|
||||||
COPY backend/ ./
|
COPY backend/ ./
|
||||||
COPY --from=builder /app/frontend/dist ./public
|
COPY --from=builder /app/frontend/dist ./public
|
||||||
|
|
||||||
|
|||||||
115
README.md
@@ -7,70 +7,77 @@ A modern, self-hosted team messaging Progressive Web App (PWA) built for small t
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🔐 **Authentication** — Login, remember me, forced password change on first login
|
### Messaging
|
||||||
- 💬 **Real-time messaging** — WebSocket (Socket.io) powered chat
|
- **Real-time messaging** — WebSocket-powered (Socket.io); messages appear instantly across all clients
|
||||||
- 👥 **Public channels** — Admin-created, all users auto-joined
|
- **Image attachments** — Attach and send images; auto-compressed client-side before upload
|
||||||
- 🔒 **Private groups** — User-created, owner-managed
|
- **Message replies** — Quote and reply to any message with an inline preview
|
||||||
- 📷 **Image uploads** — Attach images to messages
|
- **Emoji reactions** — Quick-react with common emojis or open the full emoji picker; one reaction per user, replaceable
|
||||||
- 💬 **Message quoting** — Reply to any message with preview
|
- **@Mentions** — Type `@` to search and tag users with autocomplete; mentioned users receive a notification
|
||||||
- 😎 **Emoji reactions** — Quick reactions + full emoji picker
|
- **Link previews** — URLs are automatically expanded with Open Graph metadata (title, image, site name)
|
||||||
- @**Mentions** — @mention users with autocomplete, they get notified
|
- **Typing indicators** — See when others are composing a message
|
||||||
- 🔗 **Link previews** — Auto-fetches OG metadata for URLs
|
- **Image lightbox** — Tap any image to open it full-screen with pinch-to-zoom support
|
||||||
- 📱 **PWA** — Install to home screen, works offline
|
|
||||||
- 👤 **Profiles** — Custom avatars, display names, about me
|
### Channels & Groups
|
||||||
- ⚙️ **Admin settings** — Custom logo, app name
|
- **Public channels** — Admin-created; all users are automatically added
|
||||||
- 👨💼 **User management** — Create, suspend, delete, bulk CSV import
|
- **Private groups / DMs** — Any user can create; membership is invite-only by the owner
|
||||||
- 📢 **Read-only channels** — Announcement-style public channels
|
- **Read-only channels** — Admin-configurable announcement-style channels; only admins can post
|
||||||
|
- **Support group** — A private admin-only group that receives submissions from the login page contact form
|
||||||
|
|
||||||
|
### Users & Profiles
|
||||||
|
- **Authentication** — Email/password login with optional Remember Me (30-day session)
|
||||||
|
- **Forced password change** — New users must change their password on first login
|
||||||
|
- **User profiles** — Custom display name, avatar upload, About Me text
|
||||||
|
- **Profile popup** — Click any user's avatar in chat to view their profile card
|
||||||
|
- **Admin badge** — Admins display a role badge; can be hidden per-user in Profile settings
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
- **In-app notifications** — Mention alerts with toast notifications
|
||||||
|
- **Unread indicators** — Private groups with new unread messages are highlighted and bolded in the sidebar
|
||||||
|
- **Web Push notifications** — Badge and push notifications for mentions and new private messages when the app is backgrounded or closed (requires HTTPS)
|
||||||
|
|
||||||
|
### Admin & Settings
|
||||||
|
- **User Manager** — Create, suspend, activate, delete users; reset passwords; change roles
|
||||||
|
- **Bulk CSV import** — Import multiple users at once from a CSV file
|
||||||
|
- **App branding** — Customize app name, logo, New Chat icon, and Group Info icon via the Settings panel
|
||||||
|
- **Reset to defaults** — One-click reset of all branding customizations
|
||||||
|
- **Version display** — Current app version shown in the Settings panel
|
||||||
|
|
||||||
|
### PWA
|
||||||
|
- **Installable** — Install to home screen on mobile and desktop via the browser install prompt
|
||||||
|
- **Dynamic app icon** — Uploaded logo is automatically resized to 192×192 and 512×512 and used as the PWA shortcut icon
|
||||||
|
- **Dynamic manifest** — App name and icons in the PWA manifest update live when changed in Settings
|
||||||
|
- **Offline fallback** — Basic offline support via service worker caching
|
||||||
|
|
||||||
|
### Contact Form
|
||||||
|
- **Login page contact form** — A "Contact Support" button on the login page opens a form (name, email, message, math captcha) that posts directly into the admin Support group
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Tech Stack
|
||||||
|
|
||||||
### Prerequisites
|
| Layer | Technology |
|
||||||
- Docker & Docker Compose
|
|---|---|
|
||||||
|
| Backend | Node.js, Express, Socket.io |
|
||||||
### 1. Build a versioned image
|
| Database | SQLite (better-sqlite3) |
|
||||||
|
| Frontend | React 18, Vite |
|
||||||
```bash
|
| Image processing | sharp |
|
||||||
# Build and tag as v1.0.0 (also tags :latest)
|
| Push notifications | web-push (VAPID) |
|
||||||
./build.sh 1.0.0
|
| Containerization | Docker, Docker Compose |
|
||||||
|
| Reverse proxy / SSL | Caddy (recommended) |
|
||||||
# Build latest only
|
|
||||||
./build.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Deploy with Docker Compose
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env — set TEAMCHAT_VERSION, admin credentials, JWT_SECRET
|
|
||||||
nano .env
|
|
||||||
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
docker compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
App will be available at **http://localhost:3000**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Release Workflow
|
## Requirements
|
||||||
|
|
||||||
TeamChat uses a **build-then-run** pattern. You build the image once on your build machine (or CI), then the compose file just runs the pre-built image — no build step at deploy time.
|
- **Docker** and **Docker Compose v2**
|
||||||
|
- A domain name with DNS pointed at your server (required for HTTPS and Web Push notifications)
|
||||||
|
- Ports **80** and **443** open on your server firewall (if using Caddy for SSL)
|
||||||
|
|
||||||
```
|
---
|
||||||
┌─────────────────────┐ ┌──────────────────────────┐
|
|
||||||
│ Build machine / CI │ │ Server / Portainer │
|
|
||||||
│ │ │ │
|
|
||||||
│ ./build.sh 1.2.0 │─────▶│ TEAMCHAT_VERSION=1.2.0 │
|
|
||||||
│ (or push to │ │ docker compose up -d │
|
|
||||||
│ registry first) │ │ │
|
|
||||||
└─────────────────────┘ └──────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build script usage
|
## Building the Image
|
||||||
|
|
||||||
|
All builds use `build.sh`. No host Node.js installation is required — `npm install` and the Vite build run inside Docker.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build and tag as :latest only
|
# Build and tag as :latest only
|
||||||
|
|||||||
7
about.json.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"built_with": "Node.js · Express · Socket.io · SQLite · React · Vite · Claude.ai",
|
||||||
|
"developer": "Your Name or Organization",
|
||||||
|
"license": "AGPL 3.0",
|
||||||
|
"license_url": "https://www.gnu.org/licenses/agpl-3.0.html",
|
||||||
|
"description": "Self-hosted, privacy-first team messaging."
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "teamchat-backend",
|
"name": "jama-backend",
|
||||||
"version": "1.0.0",
|
"version": "0.3.0",
|
||||||
"description": "TeamChat backend server",
|
"description": "TeamChat backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ app.use('/uploads', express.static('/app/uploads'));
|
|||||||
// API Routes
|
// API Routes
|
||||||
app.use('/api/auth', require('./routes/auth'));
|
app.use('/api/auth', require('./routes/auth'));
|
||||||
app.use('/api/users', require('./routes/users'));
|
app.use('/api/users', require('./routes/users'));
|
||||||
app.use('/api/groups', require('./routes/groups'));
|
app.use('/api/groups', require('./routes/groups')(io));
|
||||||
app.use('/api/messages', require('./routes/messages'));
|
app.use('/api/messages', require('./routes/messages'));
|
||||||
app.use('/api/settings', require('./routes/settings'));
|
app.use('/api/settings', require('./routes/settings'));
|
||||||
|
app.use('/api/about', require('./routes/about'));
|
||||||
app.use('/api/push', pushRouter);
|
app.use('/api/push', pushRouter);
|
||||||
|
|
||||||
// Link preview proxy
|
// Link preview proxy
|
||||||
@@ -128,6 +129,9 @@ io.on('connection', (socket) => {
|
|||||||
// Broadcast online status
|
// Broadcast online status
|
||||||
io.emit('user:online', { userId });
|
io.emit('user:online', { userId });
|
||||||
|
|
||||||
|
// Join personal room for direct notifications
|
||||||
|
socket.join(`user:${userId}`);
|
||||||
|
|
||||||
// Join rooms for all user's groups
|
// Join rooms for all user's groups
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
|
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
|
||||||
@@ -136,6 +140,16 @@ io.on('connection', (socket) => {
|
|||||||
const privateGroups = db.prepare("SELECT group_id FROM group_members WHERE user_id = ?").all(userId);
|
const privateGroups = db.prepare("SELECT group_id FROM group_members WHERE user_id = ?").all(userId);
|
||||||
for (const g of privateGroups) socket.join(`group:${g.group_id}`);
|
for (const g of privateGroups) socket.join(`group:${g.group_id}`);
|
||||||
|
|
||||||
|
// When a new group is created and pushed to this socket, join its room
|
||||||
|
socket.on('group:join-room', ({ groupId }) => {
|
||||||
|
socket.join(`group:${groupId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When a user leaves a group, remove them from the socket room
|
||||||
|
socket.on('group:leave-room', ({ groupId }) => {
|
||||||
|
socket.leave(`group:${groupId}`);
|
||||||
|
});
|
||||||
|
|
||||||
// Handle new message
|
// Handle new message
|
||||||
socket.on('message:send', async (data) => {
|
socket.on('message:send', async (data) => {
|
||||||
const { groupId, content, replyToId, imageUrl, linkPreview } = data;
|
const { groupId, content, replyToId, imageUrl, linkPreview } = data;
|
||||||
@@ -271,12 +285,31 @@ io.on('connection', (socket) => {
|
|||||||
socket.on('message:delete', (data) => {
|
socket.on('message:delete', (data) => {
|
||||||
const { messageId } = data;
|
const { messageId } = data;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const message = db.prepare('SELECT m.*, g.type as group_type, g.owner_id as group_owner_id FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?').get(messageId);
|
const message = db.prepare(`
|
||||||
|
SELECT m.*, g.type as group_type, g.owner_id as group_owner_id, g.is_direct
|
||||||
|
FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?
|
||||||
|
`).get(messageId);
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
|
|
||||||
const canDelete = message.user_id === userId ||
|
const isAdmin = socket.user.role === 'admin';
|
||||||
(socket.user.role === 'admin' && message.group_type === 'public') ||
|
const isOwner = message.group_owner_id === userId;
|
||||||
(message.group_type === 'private' && message.group_owner_id === userId);
|
const isAuthor = message.user_id === userId;
|
||||||
|
|
||||||
|
// Rules:
|
||||||
|
// 1. Author can always delete their own message
|
||||||
|
// 2. Admin can delete in any public group or any group they're a member of
|
||||||
|
// 3. Group owner can delete any message in their group
|
||||||
|
// 4. In direct messages: author + owner rules apply (no blanket block)
|
||||||
|
let canDelete = isAuthor || isOwner;
|
||||||
|
if (!canDelete && isAdmin) {
|
||||||
|
if (message.group_type === 'public') {
|
||||||
|
canDelete = true;
|
||||||
|
} else {
|
||||||
|
// Admin can delete in private/direct groups they're a member of
|
||||||
|
const membership = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(message.group_id, userId);
|
||||||
|
if (membership) canDelete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!canDelete) return;
|
if (!canDelete) return;
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,22 @@ function initDb() {
|
|||||||
}
|
}
|
||||||
} catch (e) { console.error('[DB] active_sessions migration error:', e.message); }
|
} catch (e) { console.error('[DB] active_sessions migration error:', e.message); }
|
||||||
|
|
||||||
|
// Migration: add is_direct for user-to-user direct messages
|
||||||
|
try {
|
||||||
|
db.exec("ALTER TABLE groups ADD COLUMN is_direct INTEGER NOT NULL DEFAULT 0");
|
||||||
|
console.log('[DB] Migration: added is_direct column');
|
||||||
|
} catch (e) { /* column already exists */ }
|
||||||
|
|
||||||
|
// Migration: store both peer IDs so direct-message names survive member leave
|
||||||
|
try {
|
||||||
|
db.exec("ALTER TABLE groups ADD COLUMN direct_peer1_id INTEGER");
|
||||||
|
console.log('[DB] Migration: added direct_peer1_id column');
|
||||||
|
} catch (e) { /* column already exists */ }
|
||||||
|
try {
|
||||||
|
db.exec("ALTER TABLE groups ADD COLUMN direct_peer2_id INTEGER");
|
||||||
|
console.log('[DB] Migration: added direct_peer2_id column');
|
||||||
|
} catch (e) { /* column already exists */ }
|
||||||
|
|
||||||
console.log('[DB] Schema initialized');
|
console.log('[DB] Schema initialized');
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|||||||
40
backend/src/routes/about.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const ABOUT_FILE = '/app/data/about.json';
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
built_with: 'Node.js · Express · Socket.io · SQLite · React · Vite · Claude.ai',
|
||||||
|
developer: 'Ricky Stretch',
|
||||||
|
license: 'AGPL 3.0',
|
||||||
|
license_url: 'https://www.gnu.org/licenses/agpl-3.0.html',
|
||||||
|
description: 'Self-hosted, privacy-first team messaging.',
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /api/about — public, no auth required
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
let overrides = {};
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(ABOUT_FILE)) {
|
||||||
|
const raw = fs.readFileSync(ABOUT_FILE, 'utf8');
|
||||||
|
overrides = JSON.parse(raw);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('about.json parse error:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version always comes from the runtime env (same source as Settings window)
|
||||||
|
const about = {
|
||||||
|
...DEFAULTS,
|
||||||
|
...overrides,
|
||||||
|
version: process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Never expose docker_image — removed from UI
|
||||||
|
delete about.docker_image;
|
||||||
|
|
||||||
|
res.json({ about });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -3,12 +3,50 @@ const router = express.Router();
|
|||||||
const { getDb } = require('../models/db');
|
const { getDb } = require('../models/db');
|
||||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Helper: emit group:new to all members of a group
|
||||||
|
function emitGroupNew(io, groupId) {
|
||||||
|
const db = getDb();
|
||||||
|
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
||||||
|
if (!group) return;
|
||||||
|
if (group.type === 'public') {
|
||||||
|
io.emit('group:new', { group });
|
||||||
|
} else {
|
||||||
|
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
|
||||||
|
for (const m of members) {
|
||||||
|
io.to(`user:${m.user_id}`).emit('group:new', { group });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: emit group:deleted to all members
|
||||||
|
function emitGroupDeleted(io, groupId, members) {
|
||||||
|
for (const uid of members) {
|
||||||
|
io.to(`user:${uid}`).emit('group:deleted', { groupId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: emit group:updated to all members
|
||||||
|
function emitGroupUpdated(io, groupId) {
|
||||||
|
const db = getDb();
|
||||||
|
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
||||||
|
if (!group) return;
|
||||||
|
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
|
||||||
|
const uids = group.type === 'public'
|
||||||
|
? db.prepare("SELECT id as user_id FROM users WHERE status = 'active'").all()
|
||||||
|
: members;
|
||||||
|
for (const m of uids) {
|
||||||
|
io.to(`user:${m.user_id}`).emit('group:updated', { group });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject io into routes
|
||||||
|
module.exports = (io) => {
|
||||||
|
|
||||||
// Get all groups for current user
|
// Get all groups for current user
|
||||||
router.get('/', authMiddleware, (req, res) => {
|
router.get('/', authMiddleware, (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
// Public groups (all users are members)
|
|
||||||
const publicGroups = db.prepare(`
|
const publicGroups = db.prepare(`
|
||||||
SELECT g.*,
|
SELECT g.*,
|
||||||
(SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count,
|
(SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count,
|
||||||
@@ -19,8 +57,8 @@ router.get('/', authMiddleware, (req, res) => {
|
|||||||
ORDER BY g.is_default DESC, g.name ASC
|
ORDER BY g.is_default DESC, g.name ASC
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
// Private groups (user is a member)
|
// For direct messages, replace name with opposite user's display name
|
||||||
const privateGroups = db.prepare(`
|
const privateGroupsRaw = db.prepare(`
|
||||||
SELECT g.*,
|
SELECT g.*,
|
||||||
u.name as owner_name,
|
u.name as owner_name,
|
||||||
(SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count,
|
(SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count,
|
||||||
@@ -33,34 +71,98 @@ router.get('/', authMiddleware, (req, res) => {
|
|||||||
ORDER BY last_message_at DESC NULLS LAST
|
ORDER BY last_message_at DESC NULLS LAST
|
||||||
`).all(userId);
|
`).all(userId);
|
||||||
|
|
||||||
|
// For direct groups, set the name to the other user's display name
|
||||||
|
// Uses direct_peer1_id / direct_peer2_id so the name survives after a user leaves
|
||||||
|
const privateGroups = privateGroupsRaw.map(g => {
|
||||||
|
if (g.is_direct) {
|
||||||
|
// Backfill peer IDs for groups created before this migration
|
||||||
|
if (!g.direct_peer1_id || !g.direct_peer2_id) {
|
||||||
|
const peers = db.prepare('SELECT user_id FROM group_members WHERE group_id = ? LIMIT 2').all(g.id);
|
||||||
|
if (peers.length === 2) {
|
||||||
|
db.prepare('UPDATE groups SET direct_peer1_id = ?, direct_peer2_id = ? WHERE id = ?')
|
||||||
|
.run(peers[0].user_id, peers[1].user_id, g.id);
|
||||||
|
g.direct_peer1_id = peers[0].user_id;
|
||||||
|
g.direct_peer2_id = peers[1].user_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const otherUserId = g.direct_peer1_id === userId ? g.direct_peer2_id : g.direct_peer1_id;
|
||||||
|
if (otherUserId) {
|
||||||
|
const other = db.prepare('SELECT display_name, name FROM users WHERE id = ?').get(otherUserId);
|
||||||
|
if (other) g.name = other.display_name || other.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ publicGroups, privateGroups });
|
res.json({ publicGroups, privateGroups });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create group
|
// Create group
|
||||||
router.post('/', authMiddleware, (req, res) => {
|
router.post('/', authMiddleware, (req, res) => {
|
||||||
const { name, type, memberIds, isReadonly } = req.body;
|
const { name, type, memberIds, isReadonly, isDirect } = req.body;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
if (type === 'public' && req.user.role !== 'admin') {
|
if (type === 'public' && req.user.role !== 'admin') {
|
||||||
return res.status(403).json({ error: 'Only admins can create public groups' });
|
return res.status(403).json({ error: 'Only admins can create public groups' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Direct message: find or create
|
||||||
|
if (isDirect && memberIds && memberIds.length === 1) {
|
||||||
|
const otherUserId = memberIds[0];
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Check if a direct group already exists between these two users
|
||||||
|
const existing = db.prepare(`
|
||||||
|
SELECT g.id FROM groups g
|
||||||
|
JOIN group_members gm1 ON gm1.group_id = g.id AND gm1.user_id = ?
|
||||||
|
JOIN group_members gm2 ON gm2.group_id = g.id AND gm2.user_id = ?
|
||||||
|
WHERE g.is_direct = 1
|
||||||
|
LIMIT 1
|
||||||
|
`).get(userId, otherUserId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(existing.id);
|
||||||
|
// Ensure current user is still a member (may have left)
|
||||||
|
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(existing.id, userId);
|
||||||
|
// Re-set readonly to false so both can post again
|
||||||
|
db.prepare("UPDATE groups SET is_readonly = 0, owner_id = NULL, updated_at = datetime('now') WHERE id = ?").run(existing.id);
|
||||||
|
return res.json({ group: db.prepare('SELECT * FROM groups WHERE id = ?').get(existing.id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get other user's display name for the group name (stored internally, overridden per-user on fetch)
|
||||||
|
const otherUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(otherUserId);
|
||||||
|
const dmName = (otherUser?.display_name || otherUser?.name) + ' ↔ ' + (req.user.display_name || req.user.name);
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO groups (name, type, owner_id, is_readonly, is_direct, direct_peer1_id, direct_peer2_id)
|
||||||
|
VALUES (?, 'private', NULL, 0, 1, ?, ?)
|
||||||
|
`).run(dmName, userId, otherUserId);
|
||||||
|
|
||||||
|
const groupId = result.lastInsertRowid;
|
||||||
|
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, userId);
|
||||||
|
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, otherUserId);
|
||||||
|
|
||||||
|
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
||||||
|
|
||||||
|
// Notify both users via socket
|
||||||
|
emitGroupNew(io, groupId);
|
||||||
|
|
||||||
|
return res.json({ group });
|
||||||
|
}
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO groups (name, type, owner_id, is_readonly)
|
INSERT INTO groups (name, type, owner_id, is_readonly, is_direct)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, 0)
|
||||||
`).run(name, type || 'private', req.user.id, isReadonly ? 1 : 0);
|
`).run(name, type || 'private', req.user.id, isReadonly ? 1 : 0);
|
||||||
|
|
||||||
const groupId = result.lastInsertRowid;
|
const groupId = result.lastInsertRowid;
|
||||||
|
|
||||||
if (type === 'public') {
|
if (type === 'public') {
|
||||||
// Add all users to public group
|
|
||||||
const allUsers = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
|
const allUsers = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
|
||||||
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
||||||
for (const u of allUsers) insert.run(groupId, u.id);
|
for (const u of allUsers) insert.run(groupId, u.id);
|
||||||
} else {
|
} else {
|
||||||
// Add creator
|
|
||||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, req.user.id);
|
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, req.user.id);
|
||||||
// Add other members
|
|
||||||
if (memberIds && memberIds.length > 0) {
|
if (memberIds && memberIds.length > 0) {
|
||||||
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
||||||
for (const uid of memberIds) insert.run(groupId, uid);
|
for (const uid of memberIds) insert.run(groupId, uid);
|
||||||
@@ -68,6 +170,10 @@ router.post('/', authMiddleware, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
||||||
|
|
||||||
|
// Notify all members via socket
|
||||||
|
emitGroupNew(io, groupId);
|
||||||
|
|
||||||
res.json({ group });
|
res.json({ group });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,14 +183,14 @@ router.patch('/:id/rename', authMiddleware, (req, res) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
||||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||||
|
|
||||||
if (group.is_default) return res.status(403).json({ error: 'Cannot rename default group' });
|
if (group.is_default) return res.status(403).json({ error: 'Cannot rename default group' });
|
||||||
|
if (group.is_direct) return res.status(403).json({ error: 'Cannot rename a direct message' });
|
||||||
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can rename public groups' });
|
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can rename public groups' });
|
||||||
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') {
|
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') {
|
||||||
return res.status(403).json({ error: 'Only owner can rename private group' });
|
return res.status(403).json({ error: 'Only owner can rename private group' });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name, group.id);
|
db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name, group.id);
|
||||||
|
emitGroupUpdated(io, group.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,15 +214,37 @@ router.post('/:id/members', authMiddleware, (req, res) => {
|
|||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
||||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||||
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot manually add members to public groups' });
|
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot manually add members to public groups' });
|
||||||
|
if (group.is_direct) return res.status(400).json({ error: 'Cannot add members to a direct message' });
|
||||||
if (group.owner_id !== req.user.id && req.user.role !== 'admin') {
|
if (group.owner_id !== req.user.id && req.user.role !== 'admin') {
|
||||||
return res.status(403).json({ error: 'Only owner can add members' });
|
return res.status(403).json({ error: 'Only owner can add members' });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(group.id, userId);
|
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(group.id, userId);
|
||||||
|
|
||||||
|
// Post a system message so all members see who was added
|
||||||
|
const addedUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
|
||||||
|
const addedName = addedUser?.display_name || addedUser?.name || 'Unknown';
|
||||||
|
const sysResult = db.prepare(`
|
||||||
|
INSERT INTO messages (group_id, user_id, content, type)
|
||||||
|
VALUES (?, ?, ?, 'system')
|
||||||
|
`).run(group.id, userId, `${addedName} has joined the conversation.`);
|
||||||
|
const sysMsg = db.prepare(`
|
||||||
|
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
|
||||||
|
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
|
||||||
|
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
|
||||||
|
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
|
||||||
|
`).get(sysResult.lastInsertRowid);
|
||||||
|
sysMsg.reactions = [];
|
||||||
|
io.to(`group:${group.id}`).emit('message:new', sysMsg);
|
||||||
|
|
||||||
|
// Join all of the added user's active sockets to the group room server-side,
|
||||||
|
// so they receive messages immediately without needing a client round-trip
|
||||||
|
io.in(`user:${userId}`).socketsJoin(`group:${group.id}`);
|
||||||
|
// Notify the added user in real-time so their sidebar updates without a refresh
|
||||||
|
io.to(`user:${userId}`).emit('group:new', { group });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove a member from a private group (owner or admin only)
|
// Remove a member from a private group
|
||||||
router.delete('/:id/members/:userId', authMiddleware, (req, res) => {
|
router.delete('/:id/members/:userId', authMiddleware, (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
||||||
@@ -126,9 +254,7 @@ router.delete('/:id/members/:userId', authMiddleware, (req, res) => {
|
|||||||
return res.status(403).json({ error: 'Only owner or admin can remove members' });
|
return res.status(403).json({ error: 'Only owner or admin can remove members' });
|
||||||
}
|
}
|
||||||
const targetId = parseInt(req.params.userId);
|
const targetId = parseInt(req.params.userId);
|
||||||
if (targetId === group.owner_id) {
|
if (targetId === group.owner_id) return res.status(400).json({ error: 'Cannot remove the group owner' });
|
||||||
return res.status(400).json({ error: 'Cannot remove the group owner' });
|
|
||||||
}
|
|
||||||
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, targetId);
|
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, targetId);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
@@ -140,11 +266,43 @@ router.delete('/:id/leave', authMiddleware, (req, res) => {
|
|||||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||||
if (group.type === 'public') return res.status(400).json({ error: 'Cannot leave public groups' });
|
if (group.type === 'public') return res.status(400).json({ error: 'Cannot leave public groups' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, req.user.id);
|
const userId = req.user.id;
|
||||||
|
const leaverName = req.user.display_name || req.user.name;
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, userId);
|
||||||
|
|
||||||
|
// Post a system message so remaining members see the leave notice
|
||||||
|
const sysResult = db.prepare(`
|
||||||
|
INSERT INTO messages (group_id, user_id, content, type)
|
||||||
|
VALUES (?, ?, ?, 'system')
|
||||||
|
`).run(group.id, userId, `${leaverName} has left the conversation.`);
|
||||||
|
|
||||||
|
const sysMsg = db.prepare(`
|
||||||
|
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
|
||||||
|
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
|
||||||
|
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
|
||||||
|
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
|
||||||
|
`).get(sysResult.lastInsertRowid);
|
||||||
|
sysMsg.reactions = [];
|
||||||
|
|
||||||
|
// Broadcast to remaining members in the group room
|
||||||
|
io.to(`group:${group.id}`).emit('message:new', sysMsg);
|
||||||
|
|
||||||
|
if (group.is_direct) {
|
||||||
|
// Make remaining user owner so they can still manage the conversation
|
||||||
|
const remaining = db.prepare('SELECT user_id FROM group_members WHERE group_id = ? LIMIT 1').get(group.id);
|
||||||
|
if (remaining) {
|
||||||
|
db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(remaining.user_id, group.id);
|
||||||
|
}
|
||||||
|
// Tell the leaver's socket to leave the group room and remove from sidebar
|
||||||
|
io.to(`user:${userId}`).emit('group:deleted', { groupId: group.id });
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin take ownership of private group
|
// Admin take ownership
|
||||||
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) => {
|
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?").run(req.user.id, req.params.id);
|
db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?").run(req.user.id, req.params.id);
|
||||||
@@ -152,7 +310,7 @@ router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) =
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete group (admin or private group owner)
|
// Delete group
|
||||||
router.delete('/:id', authMiddleware, (req, res) => {
|
router.delete('/:id', authMiddleware, (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
||||||
@@ -163,8 +321,21 @@ router.delete('/:id', authMiddleware, (req, res) => {
|
|||||||
return res.status(403).json({ error: 'Only owner or admin can delete private groups' });
|
return res.status(403).json({ error: 'Only owner or admin can delete private groups' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect members before deleting
|
||||||
|
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(group.id).map(m => m.user_id);
|
||||||
|
// Add all active users for public groups
|
||||||
|
if (group.type === 'public') {
|
||||||
|
const all = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
|
||||||
|
all.forEach(u => { if (!members.includes(u.id)) members.push(u.id); });
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare('DELETE FROM groups WHERE id = ?').run(group.id);
|
db.prepare('DELETE FROM groups WHERE id = ?').run(group.id);
|
||||||
|
|
||||||
|
// Notify all affected users
|
||||||
|
emitGroupDeleted(io, group.id, members);
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
return router;
|
||||||
|
};
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ router.get('/', (req, res) => {
|
|||||||
const admin = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
|
const admin = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
|
||||||
if (admin) obj.admin_email = admin.email;
|
if (admin) obj.admin_email = admin.email;
|
||||||
// Expose app version from Docker build arg env var
|
// Expose app version from Docker build arg env var
|
||||||
obj.app_version = process.env.TEAMCHAT_VERSION || 'dev';
|
obj.app_version = process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev';
|
||||||
|
obj.user_pass = process.env.USER_PASS || 'user@1234';
|
||||||
res.json({ settings: obj });
|
res.json({ settings: obj });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ const express = require('express');
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb, addUserToPublicGroups } = require('../models/db');
|
const { getDb, addUserToPublicGroups } = require('../models/db');
|
||||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||||
@@ -23,6 +22,29 @@ const uploadAvatar = multer({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolve unique name: "John Doe" exists → return "John Doe (1)", then "(2)" etc.
|
||||||
|
function resolveUniqueName(db, baseName, excludeId = null) {
|
||||||
|
const existing = db.prepare(
|
||||||
|
"SELECT name FROM users WHERE status != 'deleted' AND id != ? AND (name = ? OR name LIKE ?)"
|
||||||
|
).all(excludeId ?? -1, baseName, `${baseName} (%)`);
|
||||||
|
if (existing.length === 0) return baseName;
|
||||||
|
let max = 0;
|
||||||
|
for (const u of existing) {
|
||||||
|
const m = u.name.match(/\((\d+)\)$/);
|
||||||
|
if (m) max = Math.max(max, parseInt(m[1]));
|
||||||
|
else max = Math.max(max, 0);
|
||||||
|
}
|
||||||
|
return `${baseName} (${max + 1})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidEmail(email) {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultPassword(db) {
|
||||||
|
return process.env.USER_PASS || 'user@1234';
|
||||||
|
}
|
||||||
|
|
||||||
// List users (admin)
|
// List users (admin)
|
||||||
router.get('/', authMiddleware, adminMiddleware, (req, res) => {
|
router.get('/', authMiddleware, adminMiddleware, (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -34,7 +56,7 @@ router.get('/', authMiddleware, adminMiddleware, (req, res) => {
|
|||||||
res.json({ users });
|
res.json({ users });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get single user profile (public-ish for mentions)
|
// Search users (public-ish for mentions/add-member)
|
||||||
router.get('/search', authMiddleware, (req, res) => {
|
router.get('/search', authMiddleware, (req, res) => {
|
||||||
const { q } = req.query;
|
const { q } = req.query;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -46,63 +68,90 @@ router.get('/search', authMiddleware, (req, res) => {
|
|||||||
res.json({ users });
|
res.json({ users });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create user (admin)
|
// Check if a display name is already taken (excludes self)
|
||||||
|
router.get('/check-display-name', authMiddleware, (req, res) => {
|
||||||
|
const { name } = req.query;
|
||||||
|
if (!name) return res.json({ taken: false });
|
||||||
|
const db = getDb();
|
||||||
|
const conflict = db.prepare(
|
||||||
|
"SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'"
|
||||||
|
).get(name, req.user.id);
|
||||||
|
res.json({ taken: !!conflict });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create user (admin) — req 3: skip duplicate email, req 4: suffix duplicate names
|
||||||
router.post('/', authMiddleware, adminMiddleware, (req, res) => {
|
router.post('/', authMiddleware, adminMiddleware, (req, res) => {
|
||||||
const { name, email, password, role } = req.body;
|
const { name, email, password, role } = req.body;
|
||||||
if (!name || !email || !password) return res.status(400).json({ error: 'Name, email, password required' });
|
if (!name || !email) return res.status(400).json({ error: 'Name and email required' });
|
||||||
|
if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email address' });
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||||
if (exists) return res.status(400).json({ error: 'Email already in use' });
|
if (exists) return res.status(400).json({ error: 'Email already in use' });
|
||||||
|
|
||||||
const hash = bcrypt.hashSync(password, 10);
|
const resolvedName = resolveUniqueName(db, name.trim());
|
||||||
|
const pw = (password || '').trim() || getDefaultPassword(db);
|
||||||
|
const hash = bcrypt.hashSync(pw, 10);
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO users (name, email, password, role, status, must_change_password)
|
INSERT INTO users (name, email, password, role, status, must_change_password)
|
||||||
VALUES (?, ?, ?, ?, 'active', 1)
|
VALUES (?, ?, ?, ?, 'active', 1)
|
||||||
`).run(name, email, hash, role === 'admin' ? 'admin' : 'member');
|
`).run(resolvedName, email, hash, role === 'admin' ? 'admin' : 'member');
|
||||||
|
|
||||||
addUserToPublicGroups(result.lastInsertRowid);
|
addUserToPublicGroups(result.lastInsertRowid);
|
||||||
const user = db.prepare('SELECT id, name, email, role, status, must_change_password, created_at FROM users WHERE id = ?').get(result.lastInsertRowid);
|
const user = db.prepare('SELECT id, name, email, role, status, must_change_password, created_at FROM users WHERE id = ?').get(result.lastInsertRowid);
|
||||||
res.json({ user });
|
res.json({ user });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bulk create users via CSV data
|
// Bulk create users
|
||||||
router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => {
|
router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => {
|
||||||
const { users } = req.body; // array of {name, email, password, role}
|
const { users } = req.body;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const results = { created: [], errors: [] };
|
const results = { created: [], skipped: [] };
|
||||||
|
const seenEmails = new Set();
|
||||||
|
const defaultPw = getDefaultPassword(db);
|
||||||
|
|
||||||
const insertUser = db.prepare(`
|
const insertUser = db.prepare(`
|
||||||
INSERT INTO users (name, email, password, role, status, must_change_password)
|
INSERT INTO users (name, email, password, role, status, must_change_password)
|
||||||
VALUES (?, ?, ?, ?, 'active', 1)
|
VALUES (?, ?, ?, ?, 'active', 1)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const transaction = db.transaction((users) => {
|
for (const u of users) {
|
||||||
for (const u of users) {
|
const email = (u.email || '').trim().toLowerCase();
|
||||||
if (!u.name || !u.email || !u.password) {
|
const name = (u.name || '').trim();
|
||||||
results.errors.push({ email: u.email, error: 'Missing required fields' });
|
if (!name || !email) { results.skipped.push({ email: email || '(blank)', reason: 'Missing name or email' }); continue; }
|
||||||
continue;
|
if (!isValidEmail(email)) { results.skipped.push({ email, reason: 'Invalid email address' }); continue; }
|
||||||
}
|
if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
|
||||||
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(u.email);
|
seenEmails.add(email);
|
||||||
if (exists) {
|
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||||
results.errors.push({ email: u.email, error: 'Email already exists' });
|
if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; }
|
||||||
continue;
|
try {
|
||||||
}
|
const resolvedName = resolveUniqueName(db, name);
|
||||||
try {
|
const pw = (u.password || '').trim() || defaultPw;
|
||||||
const hash = bcrypt.hashSync(u.password, 10);
|
const hash = bcrypt.hashSync(pw, 10);
|
||||||
const r = insertUser.run(u.name, u.email, hash, u.role === 'admin' ? 'admin' : 'member');
|
const r = insertUser.run(resolvedName, email, hash, u.role === 'admin' ? 'admin' : 'member');
|
||||||
addUserToPublicGroups(r.lastInsertRowid);
|
addUserToPublicGroups(r.lastInsertRowid);
|
||||||
results.created.push(u.email);
|
results.created.push(email);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
results.errors.push({ email: u.email, error: e.message });
|
results.skipped.push({ email, reason: e.message });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
transaction(users);
|
|
||||||
res.json(results);
|
res.json(results);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update user name (admin only — req 5)
|
||||||
|
router.patch('/:id/name', authMiddleware, adminMiddleware, (req, res) => {
|
||||||
|
const { name } = req.body;
|
||||||
|
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
|
||||||
|
const db = getDb();
|
||||||
|
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||||
|
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||||
|
// Pass the target's own id so their current name is excluded from the duplicate check
|
||||||
|
const resolvedName = resolveUniqueName(db, name.trim(), req.params.id);
|
||||||
|
db.prepare("UPDATE users SET name = ?, updated_at = datetime('now') WHERE id = ?").run(resolvedName, target.id);
|
||||||
|
res.json({ success: true, name: resolvedName });
|
||||||
|
});
|
||||||
|
|
||||||
// Update user role (admin)
|
// Update user role (admin)
|
||||||
router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
|
router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
|
||||||
const { role } = req.body;
|
const { role } = req.body;
|
||||||
@@ -111,7 +160,6 @@ router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
|
|||||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||||
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' });
|
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' });
|
||||||
if (!['member', 'admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
if (!['member', 'admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||||
|
|
||||||
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, target.id);
|
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, target.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
@@ -132,7 +180,6 @@ router.patch('/:id/suspend', authMiddleware, adminMiddleware, (req, res) => {
|
|||||||
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||||
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' });
|
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' });
|
||||||
|
|
||||||
db.prepare("UPDATE users SET status = 'suspended', updated_at = datetime('now') WHERE id = ?").run(target.id);
|
db.prepare("UPDATE users SET status = 'suspended', updated_at = datetime('now') WHERE id = ?").run(target.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
@@ -150,28 +197,80 @@ router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => {
|
|||||||
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||||
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' });
|
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' });
|
||||||
|
|
||||||
db.prepare("UPDATE users SET status = 'deleted', updated_at = datetime('now') WHERE id = ?").run(target.id);
|
db.prepare("UPDATE users SET status = 'deleted', updated_at = datetime('now') WHERE id = ?").run(target.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update own profile
|
// Update own profile — display name must be unique (req 6)
|
||||||
router.patch('/me/profile', authMiddleware, (req, res) => {
|
router.patch('/me/profile', authMiddleware, (req, res) => {
|
||||||
const { displayName, aboutMe, hideAdminTag } = req.body;
|
const { displayName, aboutMe, hideAdminTag } = req.body;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
if (displayName) {
|
||||||
|
const conflict = db.prepare(
|
||||||
|
"SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'"
|
||||||
|
).get(displayName, req.user.id);
|
||||||
|
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
|
||||||
|
}
|
||||||
db.prepare("UPDATE users SET display_name = ?, about_me = ?, hide_admin_tag = ?, updated_at = datetime('now') WHERE id = ?")
|
db.prepare("UPDATE users SET display_name = ?, about_me = ?, hide_admin_tag = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
.run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, req.user.id);
|
.run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, req.user.id);
|
||||||
const user = db.prepare('SELECT id, name, email, role, status, avatar, about_me, display_name, hide_admin_tag FROM users WHERE id = ?').get(req.user.id);
|
const user = db.prepare('SELECT id, name, email, role, status, avatar, about_me, display_name, hide_admin_tag FROM users WHERE id = ?').get(req.user.id);
|
||||||
res.json({ user });
|
res.json({ user });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload avatar
|
// Upload avatar — resize if needed, skip compression for files under 500 KB
|
||||||
router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), (req, res) => {
|
router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
try {
|
||||||
const db = getDb();
|
const sharp = require('sharp');
|
||||||
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
|
const filePath = req.file.path;
|
||||||
res.json({ avatarUrl });
|
const fileSizeBytes = req.file.size;
|
||||||
|
const FIVE_HUNDRED_KB = 500 * 1024;
|
||||||
|
const MAX_DIM = 256; // max width/height in pixels
|
||||||
|
|
||||||
|
const image = sharp(filePath);
|
||||||
|
const meta = await image.metadata();
|
||||||
|
const needsResize = (meta.width > MAX_DIM || meta.height > MAX_DIM);
|
||||||
|
|
||||||
|
if (fileSizeBytes < FIVE_HUNDRED_KB && !needsResize) {
|
||||||
|
// Small enough and already correctly sized — serve as-is
|
||||||
|
} else {
|
||||||
|
// Resize (and compress only if over 500 KB)
|
||||||
|
const outPath = filePath.replace(/(\.[^.]+)$/, '_p$1');
|
||||||
|
let pipeline = sharp(filePath).resize(MAX_DIM, MAX_DIM, { fit: 'cover', withoutEnlargement: true });
|
||||||
|
if (fileSizeBytes >= FIVE_HUNDRED_KB) {
|
||||||
|
// Compress: use webp for best size/quality ratio
|
||||||
|
pipeline = pipeline.webp({ quality: 82 });
|
||||||
|
await pipeline.toFile(outPath + '.webp');
|
||||||
|
const fs = require('fs');
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
fs.renameSync(outPath + '.webp', filePath.replace(/\.[^.]+$/, '.webp'));
|
||||||
|
const newPath = filePath.replace(/\.[^.]+$/, '.webp');
|
||||||
|
const newFilename = path.basename(newPath);
|
||||||
|
const db = getDb();
|
||||||
|
const avatarUrl = `/uploads/avatars/${newFilename}`;
|
||||||
|
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
|
||||||
|
return res.json({ avatarUrl });
|
||||||
|
} else {
|
||||||
|
// Under 500 KB but needs resize — resize only, keep original format
|
||||||
|
await pipeline.toFile(outPath);
|
||||||
|
const fs = require('fs');
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
fs.renameSync(outPath, filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
|
||||||
|
res.json({ avatarUrl });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Avatar processing error:', err);
|
||||||
|
// Fall back to serving unprocessed file
|
||||||
|
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
|
||||||
|
res.json({ avatarUrl });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-latest}"
|
VERSION="${1:-0.3.0}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="jama"
|
IMAGE_NAME="jama"
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ services:
|
|||||||
- "${PORT:-3000}:3000"
|
- "${PORT:-3000}:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
||||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
|
||||||
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
||||||
|
- USER_PASS=${USER_PASS:-user@1234}
|
||||||
- PW_RESET=${PW_RESET:-false}
|
- PW_RESET=${PW_RESET:-false}
|
||||||
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
|
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
|
||||||
- APP_NAME=${APP_NAME:-jama}
|
- APP_NAME=${APP_NAME:-jama}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/icons/jama.png" />
|
<link rel="icon" type="image/png" href="/icons/jama.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
<meta name="theme-color" content="#1a73e8" />
|
<meta name="theme-color" content="#1a73e8" />
|
||||||
<meta name="description" content="jama - just another messaging app" />
|
<meta name="description" content="jama - just another messaging app" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "teamchat-frontend",
|
"name": "jama-frontend",
|
||||||
"version": "1.0.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 772 B |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 316 KiB |
@@ -5,7 +5,7 @@
|
|||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "any",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#1a73e8",
|
"theme_color": "#1a73e8",
|
||||||
"icons": [
|
"icons": [
|
||||||
@@ -33,5 +33,6 @@
|
|||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"min_width": "320px"
|
||||||
}
|
}
|
||||||
87
frontend/src/components/AboutModal.jsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
|
||||||
|
const CLAUDE_URL = 'https://claude.ai';
|
||||||
|
|
||||||
|
// Render "Built With" value — separator trails its token so it never starts a new line
|
||||||
|
function BuiltWithValue({ value }) {
|
||||||
|
if (!value) return null;
|
||||||
|
const parts = value.split('·').map(s => s.trim());
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline' }}>
|
||||||
|
{parts.map((part, i) => (
|
||||||
|
<span key={part} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{part === 'Claude.ai'
|
||||||
|
? <a href={CLAUDE_URL} target="_blank" rel="noreferrer" className="about-link">{part}</a>
|
||||||
|
: part}
|
||||||
|
{i < parts.length - 1 && <span style={{ margin: '0 4px', color: 'var(--text-tertiary)' }}>·</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AboutModal({ onClose }) {
|
||||||
|
const [settings, setSettings] = useState({ app_name: 'jama', app_version: '' });
|
||||||
|
const [about, setAbout] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||||
|
fetch('/api/about')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(({ about }) => setAbout(about))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const appName = settings.app_name || 'jama';
|
||||||
|
// Version always mirrors Settings window — from settings API (env var)
|
||||||
|
const version = settings.app_version || about?.version || '';
|
||||||
|
const a = about || {};
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
{ label: 'Version', value: version },
|
||||||
|
{ label: 'Built With', value: a.built_with, builtWith: true },
|
||||||
|
{ label: 'Developer', value: a.developer },
|
||||||
|
{ label: 'License', value: a.license, link: a.license_url },
|
||||||
|
].filter(r => r.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal about-modal">
|
||||||
|
<button className="btn-icon about-close" onClick={onClose}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="about-hero">
|
||||||
|
<img src="/icons/jama.png" alt="jama" className="about-logo" />
|
||||||
|
<h1 className="about-appname">{appName}</h1>
|
||||||
|
<p className="about-tagline">just another messaging app</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{about ? (
|
||||||
|
<>
|
||||||
|
<div className="about-table">
|
||||||
|
{rows.map(({ label, value, builtWith, link }) => (
|
||||||
|
<div className="about-row" key={label}>
|
||||||
|
<span className="about-label">{label}</span>
|
||||||
|
<span className="about-value">
|
||||||
|
{builtWith
|
||||||
|
? <BuiltWithValue value={value} />
|
||||||
|
: link
|
||||||
|
? <a href={link} target="_blank" rel="noreferrer" className="about-link">{value}</a>
|
||||||
|
: value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{a.description && <p className="about-footer">{a.description}</p>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center" style={{ padding: 24 }}><div className="spinner" /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
background: var(--surface-variant);
|
background: var(--surface-variant);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-window.empty {
|
.chat-window.empty {
|
||||||
@@ -79,11 +81,16 @@
|
|||||||
/* Messages */
|
/* Messages */
|
||||||
.messages-container {
|
.messages-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0; /* critical: allows flex child to shrink below content size */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
/* Anchor scroll to bottom so new messages appear above the input */
|
||||||
|
scroll-padding-bottom: 0;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-more-btn {
|
.load-more-btn {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import MessageInput from './MessageInput.jsx';
|
|||||||
import GroupInfoModal from './GroupInfoModal.jsx';
|
import GroupInfoModal from './GroupInfoModal.jsx';
|
||||||
import './ChatWindow.css';
|
import './ChatWindow.css';
|
||||||
|
|
||||||
export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage }) {
|
||||||
const { socket } = useSocket();
|
const { socket } = useSocket();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -23,6 +23,8 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
|||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const messagesTopRef = useRef(null);
|
const messagesTopRef = useRef(null);
|
||||||
const typingTimers = useRef({});
|
const typingTimers = useRef({});
|
||||||
|
const swipeStartX = useRef(null);
|
||||||
|
const swipeStartY = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getSettings().then(({ settings }) => {
|
api.getSettings().then(({ settings }) => {
|
||||||
@@ -33,6 +35,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
|||||||
return () => window.removeEventListener('jama:settings-changed', handler);
|
return () => window.removeEventListener('jama:settings-changed', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback((smooth = false) => {
|
const scrollToBottom = useCallback((smooth = false) => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
|
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -110,7 +113,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
|||||||
setHasMore(older.length >= 50);
|
setHasMore(older.length >= 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = async ({ content, imageFile, linkPreview }) => {
|
const handleSend = async ({ content, imageFile, linkPreview, emojiOnly }) => {
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
const replyId = replyTo?.id;
|
const replyId = replyTo?.id;
|
||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
@@ -125,7 +128,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
socket?.emit('message:send', {
|
socket?.emit('message:send', {
|
||||||
groupId: group.id, content, replyToId: replyId, linkPreview
|
groupId: group.id, content, replyToId: replyId, linkPreview, emojiOnly
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -149,8 +152,30 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTouchStart = (e) => {
|
||||||
|
swipeStartX.current = e.touches[0].clientX;
|
||||||
|
swipeStartY.current = e.touches[0].clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = (e) => {
|
||||||
|
if (swipeStartX.current === null || !onBack) return;
|
||||||
|
const dx = e.changedTouches[0].clientX - swipeStartX.current;
|
||||||
|
const dy = Math.abs(e.changedTouches[0].clientY - swipeStartY.current);
|
||||||
|
// Swipe right: at least 80px horizontal, less than 60px vertical drift
|
||||||
|
if (dx > 80 && dy < 60) {
|
||||||
|
e.preventDefault();
|
||||||
|
onBack();
|
||||||
|
}
|
||||||
|
swipeStartX.current = null;
|
||||||
|
swipeStartY.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-window">
|
<div
|
||||||
|
className="chat-window"
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="chat-header">
|
<div className="chat-header">
|
||||||
{onBack && (
|
{onBack && (
|
||||||
@@ -172,12 +197,12 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<span className="chat-header-sub">
|
<span className="chat-header-sub">
|
||||||
{group.type === 'public' ? 'Public group' : 'Private group'}
|
{group.is_direct ? 'Direct message' : group.type === 'public' ? 'Public message' : 'Private message'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-icon" onClick={() => setShowInfo(true)} title="Group info">
|
<button className="btn-icon" onClick={() => setShowInfo(true)} title="Message info">
|
||||||
{iconGroupInfo ? (
|
{iconGroupInfo ? (
|
||||||
<img src={iconGroupInfo} alt="Group info" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
<img src={iconGroupInfo} alt="Message info" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
||||||
) : (
|
) : (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="24" height="24">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="24" height="24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" />
|
||||||
@@ -203,9 +228,11 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
|||||||
message={msg}
|
message={msg}
|
||||||
prevMessage={messages[i - 1]}
|
prevMessage={messages[i - 1]}
|
||||||
currentUser={user}
|
currentUser={user}
|
||||||
|
isDirect={!!group.is_direct}
|
||||||
onReply={(m) => setReplyTo(m)}
|
onReply={(m) => setReplyTo(m)}
|
||||||
onDelete={(id) => socket?.emit('message:delete', { messageId: id })}
|
onDelete={(id) => socket?.emit('message:delete', { messageId: id })}
|
||||||
onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })}
|
onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })}
|
||||||
|
onDirectMessage={onDirectMessage}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{typing.length > 0 && (
|
{typing.length > 0 && (
|
||||||
@@ -236,7 +263,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="readonly-bar">
|
<div className="readonly-bar">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
This channel is read-only
|
This message is read-only
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -245,6 +272,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
|||||||
group={group}
|
group={group}
|
||||||
onClose={() => setShowInfo(false)}
|
onClose={() => setShowInfo(false)}
|
||||||
onUpdated={onGroupUpdated}
|
onUpdated={onGroupUpdated}
|
||||||
|
onBack={onBack}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
41
frontend/src/components/GlobalBar.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
|
||||||
|
export default function GlobalBar({ isMobile, showSidebar }) {
|
||||||
|
const { connected } = useSocket();
|
||||||
|
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||||
|
const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||||
|
window.addEventListener('jama:settings-changed', handler);
|
||||||
|
return () => window.removeEventListener('jama:settings-changed', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const appName = settings.app_name || 'jama';
|
||||||
|
const logoUrl = settings.logo_url;
|
||||||
|
|
||||||
|
// On mobile: show bar only when sidebar is visible (chat list view)
|
||||||
|
// On desktop: always show
|
||||||
|
if (isMobile && !showSidebar) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="global-bar">
|
||||||
|
<div className="global-bar-brand">
|
||||||
|
<img
|
||||||
|
src={logoUrl || '/icons/jama.png'}
|
||||||
|
alt={appName}
|
||||||
|
className="global-bar-logo"
|
||||||
|
/>
|
||||||
|
<span className="global-bar-title">{appName}</span>
|
||||||
|
</div>
|
||||||
|
{!connected && (
|
||||||
|
<span className="global-bar-offline" title="Offline">
|
||||||
|
<span className="offline-dot" />
|
||||||
|
<span className="offline-label">Offline</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { api } from '../utils/api.js';
|
|||||||
import { useToast } from '../contexts/ToastContext.jsx';
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
import Avatar from './Avatar.jsx';
|
import Avatar from './Avatar.jsx';
|
||||||
|
|
||||||
export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [members, setMembers] = useState([]);
|
const [members, setMembers] = useState([]);
|
||||||
@@ -12,12 +12,12 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
|||||||
const [newName, setNewName] = useState(group.name);
|
const [newName, setNewName] = useState(group.name);
|
||||||
const [addSearch, setAddSearch] = useState('');
|
const [addSearch, setAddSearch] = useState('');
|
||||||
const [addResults, setAddResults] = useState([]);
|
const [addResults, setAddResults] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
|
const isDirect = !!group.is_direct;
|
||||||
const isOwner = group.owner_id === user.id;
|
const isOwner = group.owner_id === user.id;
|
||||||
const isAdmin = user.role === 'admin';
|
const isAdmin = user.role === 'admin';
|
||||||
const canManage = (group.type === 'private' && isOwner) || (group.type === 'public' && isAdmin);
|
const canManage = !isDirect && ((group.type === 'private' && isOwner) || (group.type === 'public' && isAdmin));
|
||||||
const canRename = !group.is_default && ((group.type === 'public' && isAdmin) || (group.type === 'private' && isOwner));
|
const canRename = !isDirect && !group.is_default && ((group.type === 'public' && isAdmin) || (group.type === 'private' && isOwner));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (group.type === 'private') {
|
if (group.type === 'private') {
|
||||||
@@ -35,24 +35,30 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
|||||||
if (!newName.trim() || newName === group.name) { setEditing(false); return; }
|
if (!newName.trim() || newName === group.name) { setEditing(false); return; }
|
||||||
try {
|
try {
|
||||||
await api.renameGroup(group.id, newName.trim());
|
await api.renameGroup(group.id, newName.trim());
|
||||||
toast('Group renamed', 'success');
|
toast('Renamed', 'success');
|
||||||
onUpdated();
|
onUpdated();
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
} catch (e) { toast(e.message, 'error'); }
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLeave = async () => {
|
const handleLeave = async () => {
|
||||||
if (!confirm('Leave this group?')) return;
|
if (!confirm('Leave this message?')) return;
|
||||||
try {
|
try {
|
||||||
await api.leaveGroup(group.id);
|
await api.leaveGroup(group.id);
|
||||||
toast('Left group', 'success');
|
toast('Left message', 'success');
|
||||||
onUpdated();
|
|
||||||
onClose();
|
onClose();
|
||||||
|
if (isDirect) {
|
||||||
|
// For direct messages: socket group:deleted fired by server handles
|
||||||
|
// removing from sidebar and clearing active group — no manual refresh needed
|
||||||
|
} else {
|
||||||
|
onUpdated();
|
||||||
|
if (onBack) onBack();
|
||||||
|
}
|
||||||
} catch (e) { toast(e.message, 'error'); }
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTakeOwnership = async () => {
|
const handleTakeOwnership = async () => {
|
||||||
if (!confirm('Take ownership of this private group? You will be able to see all messages.')) return;
|
if (!confirm('Take ownership of this private group?')) return;
|
||||||
try {
|
try {
|
||||||
await api.takeOwnership(group.id);
|
await api.takeOwnership(group.id);
|
||||||
toast('Ownership taken', 'success');
|
toast('Ownership taken', 'success');
|
||||||
@@ -64,7 +70,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
|||||||
const handleAdd = async (u) => {
|
const handleAdd = async (u) => {
|
||||||
try {
|
try {
|
||||||
await api.addMember(group.id, u.id);
|
await api.addMember(group.id, u.id);
|
||||||
toast(`${u.display_name || u.name} added`, 'success');
|
toast(`${u.name} added`, 'success');
|
||||||
api.getMembers(group.id).then(({ members }) => setMembers(members));
|
api.getMembers(group.id).then(({ members }) => setMembers(members));
|
||||||
setAddSearch('');
|
setAddSearch('');
|
||||||
setAddResults([]);
|
setAddResults([]);
|
||||||
@@ -72,29 +78,34 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove = async (member) => {
|
const handleRemove = async (member) => {
|
||||||
if (!confirm(`Remove ${member.display_name || member.name} from this group?`)) return;
|
if (!confirm(`Remove ${member.name}?`)) return;
|
||||||
try {
|
try {
|
||||||
await api.removeMember(group.id, member.id);
|
await api.removeMember(group.id, member.id);
|
||||||
toast(`${member.display_name || member.name} removed`, 'success');
|
toast(`${member.name} removed`, 'success');
|
||||||
setMembers(prev => prev.filter(m => m.id !== member.id));
|
setMembers(prev => prev.filter(m => m.id !== member.id));
|
||||||
} catch (e) { toast(e.message, 'error'); }
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!confirm('Delete this group? This cannot be undone.')) return;
|
if (!confirm('Delete this message? This cannot be undone.')) return;
|
||||||
try {
|
try {
|
||||||
await api.deleteGroup(group.id);
|
await api.deleteGroup(group.id);
|
||||||
toast('Group deleted', 'success');
|
toast('Deleted', 'success');
|
||||||
onUpdated();
|
onUpdated();
|
||||||
onClose();
|
onClose();
|
||||||
|
if (onBack) onBack();
|
||||||
} catch (e) { toast(e.message, 'error'); }
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// For direct messages: only show Delete button (owner = remaining user after other left)
|
||||||
|
const canDeleteDirect = isDirect && isOwner;
|
||||||
|
const canDeleteRegular = !isDirect && (isOwner || (isAdmin && group.type === 'public')) && !group.is_default;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
<div className="modal">
|
<div className="modal">
|
||||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
<h2 className="modal-title" style={{ margin: 0 }}>Group Info</h2>
|
<h2 className="modal-title" style={{ margin: 0 }}>Message Info</h2>
|
||||||
<button className="btn-icon" onClick={onClose}>
|
<button className="btn-icon" onClick={onClose}>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -120,14 +131,14 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-6" style={{ gap: 8, marginTop: 4 }}>
|
<div className="flex items-center gap-6" style={{ gap: 8, marginTop: 4 }}>
|
||||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{group.type === 'public' ? 'Public channel' : 'Private group'}
|
{isDirect ? 'Direct message' : group.type === 'public' ? 'Public message' : 'Private message'}
|
||||||
</span>
|
</span>
|
||||||
{group.is_readonly && <span className="readonly-badge" style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: '#fff3e0', color: '#e65100' }}>Read-only</span>}
|
{!!group.is_readonly && <span className="readonly-badge" style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: '#fff3e0', color: '#e65100' }}>Read-only</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Members (private groups) */}
|
{/* Members — shown for private non-direct groups */}
|
||||||
{group.type === 'private' && (
|
{group.type === 'private' && !isDirect && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||||
Members ({members.length})
|
Members ({members.length})
|
||||||
@@ -136,18 +147,13 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
|||||||
{members.map(m => (
|
{members.map(m => (
|
||||||
<div key={m.id} className="flex items-center" style={{ gap: 10, padding: '6px 0' }}>
|
<div key={m.id} className="flex items-center" style={{ gap: 10, padding: '6px 0' }}>
|
||||||
<Avatar user={m} size="sm" />
|
<Avatar user={m} size="sm" />
|
||||||
<span className="flex-1 text-sm">{m.display_name || m.name}</span>
|
<span className="flex-1 text-sm">{m.name}</span>
|
||||||
{m.id === group.owner_id && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Owner</span>}
|
{m.id === group.owner_id && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Owner</span>}
|
||||||
{canManage && m.id !== group.owner_id && (
|
{canManage && m.id !== group.owner_id && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemove(m)}
|
onClick={() => handleRemove(m)}
|
||||||
title="Remove from group"
|
title="Remove"
|
||||||
style={{
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-tertiary)', padding: '2px 4px', borderRadius: 4, lineHeight: 1, transition: 'color var(--transition)' }}
|
||||||
background: 'none', border: 'none', cursor: 'pointer',
|
|
||||||
color: 'var(--text-tertiary)', padding: '2px 4px', borderRadius: 4,
|
|
||||||
lineHeight: 1, fontSize: 16,
|
|
||||||
transition: 'color var(--transition)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--error)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--error)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-tertiary)'}
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-tertiary)'}
|
||||||
>
|
>
|
||||||
@@ -159,16 +165,15 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<input className="input" placeholder="Search to add member..." value={addSearch} onChange={e => setAddSearch(e.target.value)} />
|
<input className="input" placeholder="Search to add member..." value={addSearch} onChange={e => setAddSearch(e.target.value)} />
|
||||||
{addResults.length > 0 && addSearch && (
|
{addResults.length > 0 && addSearch && (
|
||||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', marginTop: 4, maxHeight: 150, overflowY: 'auto' }}>
|
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', marginTop: 4, maxHeight: 150, overflowY: 'auto', background: 'var(--surface)' }}>
|
||||||
{addResults.filter(u => !members.find(m => m.id === u.id)).map(u => (
|
{addResults.filter(u => !members.find(m => m.id === u.id)).map(u => (
|
||||||
<button key={u.id} className="flex items-center gap-2 w-full" style={{ gap: 10, padding: '8px 12px', textAlign: 'left', transition: 'background var(--transition)' }} onClick={() => handleAdd(u)} onMouseEnter={e => e.currentTarget.style.background = 'var(--background)'} onMouseLeave={e => e.currentTarget.style.background = ''}>
|
<button key={u.id} className="flex items-center gap-2 w-full" style={{ gap: 10, padding: '8px 12px', textAlign: 'left', transition: 'background var(--transition)', color: 'var(--text-primary)' }} onClick={() => handleAdd(u)} onMouseEnter={e => e.currentTarget.style.background = 'var(--background)'} onMouseLeave={e => e.currentTarget.style.background = ''}>
|
||||||
<Avatar user={u} size="sm" />
|
<Avatar user={u} size="sm" />
|
||||||
<span className="text-sm flex-1">{u.display_name || u.name}</span>
|
<span className="text-sm flex-1">{u.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,16 +185,21 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex-col gap-2">
|
<div className="flex-col gap-2">
|
||||||
{group.type === 'private' && group.owner_id !== user.id && (
|
{/* Direct message: leave (if not already owner/last person) */}
|
||||||
|
{isDirect && !isOwner && (
|
||||||
|
<button className="btn btn-secondary w-full" onClick={handleLeave}>Leave Conversation</button>
|
||||||
|
)}
|
||||||
|
{/* Regular private: leave if not owner */}
|
||||||
|
{!isDirect && group.type === 'private' && !isOwner && (
|
||||||
<button className="btn btn-secondary w-full" onClick={handleLeave}>Leave Group</button>
|
<button className="btn btn-secondary w-full" onClick={handleLeave}>Leave Group</button>
|
||||||
)}
|
)}
|
||||||
{isAdmin && group.type === 'private' && group.owner_id !== user.id && (
|
{/* Admin take ownership (non-direct only) */}
|
||||||
<button className="btn btn-secondary w-full" onClick={handleTakeOwnership}>
|
{!isDirect && isAdmin && group.type === 'private' && !isOwner && (
|
||||||
Take Ownership (Admin)
|
<button className="btn btn-secondary w-full" onClick={handleTakeOwnership}>Take Ownership (Admin)</button>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
{(isOwner || (isAdmin && group.type === 'public')) && !group.is_default && (
|
{/* Delete */}
|
||||||
<button className="btn btn-danger w-full" onClick={handleDelete}>Delete Group</button>
|
{(canDeleteDirect || canDeleteRegular) && (
|
||||||
|
<button className="btn btn-danger w-full" onClick={handleDelete}>Delete</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,44 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-message {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 6px 0;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .system-message {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-link {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.msg-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Own bubble (primary background) — link must be white */
|
||||||
|
.msg-bubble.out .msg-link {
|
||||||
|
color: white;
|
||||||
|
text-decoration: underline;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.msg-bubble.out .msg-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Incoming bubble — link should be a dark/contrasting tone, not the same blue as bubble */
|
||||||
|
.msg-bubble.in .msg-link {
|
||||||
|
color: var(--primary-dark, #1565c0);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.message-wrapper {
|
.message-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -137,6 +175,13 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.msg-bubble {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.msg-bubble.out {
|
.msg-bubble.out {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: white;
|
||||||
@@ -144,7 +189,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.msg-bubble.in {
|
.msg-bubble.in {
|
||||||
background: white;
|
background: var(--bubble-in);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-bottom-left-radius: 4px;
|
border-bottom-left-radius: 4px;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
@@ -264,3 +309,19 @@
|
|||||||
.out .link-preview { background: rgba(255,255,255,0.15); }
|
.out .link-preview { background: rgba(255,255,255,0.15); }
|
||||||
.out .link-title { color: white; }
|
.out .link-title { color: white; }
|
||||||
.out .link-desc { color: rgba(255,255,255,0.8); }
|
.out .link-desc { color: rgba(255,255,255,0.8); }
|
||||||
|
|
||||||
|
/* Emoji-only messages: no bubble background, large size */
|
||||||
|
.msg-bubble.emoji-only {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.msg-bubble.emoji-only::after { display: none; }
|
||||||
|
|
||||||
|
.msg-text.emoji-msg {
|
||||||
|
font-size: 48px;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin: 0;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,16 +4,34 @@ import UserProfilePopup from './UserProfilePopup.jsx';
|
|||||||
import ImageLightbox from './ImageLightbox.jsx';
|
import ImageLightbox from './ImageLightbox.jsx';
|
||||||
import Picker from '@emoji-mart/react';
|
import Picker from '@emoji-mart/react';
|
||||||
import data from '@emoji-mart/data';
|
import data from '@emoji-mart/data';
|
||||||
|
import { parseTS } from '../utils/api.js';
|
||||||
import './Message.css';
|
import './Message.css';
|
||||||
|
|
||||||
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
|
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
|
||||||
|
|
||||||
function formatMsgContent(content) {
|
function formatMsgContent(content) {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
return content.replace(/@\[([^\]]+)\]\(\d+\)/g, (_, name) => `<span class="mention">@${name}</span>`);
|
// First handle @mentions
|
||||||
|
let html = content.replace(/@\[([^\]]+)\]\(\d+\)/g, (_, name) => `<span class="mention">@${name}</span>`);
|
||||||
|
// Then linkify bare URLs (not already inside a tag)
|
||||||
|
html = html.replace(/(https?:\/\/[^\s<>"]+)/g, (url) => {
|
||||||
|
// Trim trailing punctuation that's unlikely to be part of the URL
|
||||||
|
const trimmed = url.replace(/[.,!?;:)\]]+$/, '');
|
||||||
|
const trailing = url.slice(trimmed.length);
|
||||||
|
return `<a href="${trimmed}" target="_blank" rel="noopener noreferrer" class="msg-link">${trimmed}</a>${trailing}`;
|
||||||
|
});
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact }) {
|
|
||||||
|
// Detect emoji-only messages for large rendering
|
||||||
|
function isEmojiOnly(str) {
|
||||||
|
if (!str || str.length > 12) return false;
|
||||||
|
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F|\u200D|[\u{1F1E0}-\u{1F1FF}])+$/u;
|
||||||
|
return emojiRegex.test(str.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact, onDirectMessage, isDirect }) {
|
||||||
const [showActions, setShowActions] = useState(false);
|
const [showActions, setShowActions] = useState(false);
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
const wrapperRef = useRef(null);
|
const wrapperRef = useRef(null);
|
||||||
@@ -25,21 +43,36 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
|||||||
|
|
||||||
const isOwn = msg.user_id === currentUser.id;
|
const isOwn = msg.user_id === currentUser.id;
|
||||||
const isDeleted = !!msg.is_deleted;
|
const isDeleted = !!msg.is_deleted;
|
||||||
|
const isSystem = msg.type === 'system';
|
||||||
|
|
||||||
|
// These must be computed before any early returns that reference them
|
||||||
|
const showDateSep = !prevMessage ||
|
||||||
|
parseTS(msg.created_at).toDateString() !== parseTS(prevMessage.created_at).toDateString();
|
||||||
|
|
||||||
|
const prevSameUser = prevMessage && prevMessage.user_id === msg.user_id &&
|
||||||
|
prevMessage.type !== 'system' && msg.type !== 'system' &&
|
||||||
|
parseTS(msg.created_at) - parseTS(prevMessage.created_at) < 60000;
|
||||||
|
|
||||||
|
const canDelete = !msg.is_deleted && (
|
||||||
|
msg.user_id === currentUser.id ||
|
||||||
|
currentUser.role === 'admin' ||
|
||||||
|
msg.group_owner_id === currentUser.id
|
||||||
|
);
|
||||||
|
|
||||||
// Deleted messages are filtered out by ChatWindow, but guard here too
|
// Deleted messages are filtered out by ChatWindow, but guard here too
|
||||||
if (isDeleted) return null;
|
if (isDeleted) return null;
|
||||||
|
|
||||||
const canDelete = (
|
// System messages render as a simple centred notice
|
||||||
msg.user_id === currentUser.id ||
|
if (isSystem) {
|
||||||
currentUser.role === 'admin' ||
|
return (
|
||||||
(msg.group_owner_id === currentUser.id)
|
<>
|
||||||
);
|
{showDateSep && (
|
||||||
|
<div className="date-separator"><span>{formatDate(msg.created_at)}</span></div>
|
||||||
const prevSameUser = prevMessage && prevMessage.user_id === msg.user_id &&
|
)}
|
||||||
new Date(msg.created_at) - new Date(prevMessage.created_at) < 60000;
|
<div className="system-message">{msg.content}</div>
|
||||||
|
</>
|
||||||
const showDateSep = !prevMessage ||
|
);
|
||||||
new Date(msg.created_at).toDateString() !== new Date(prevMessage.created_at).toDateString();
|
}
|
||||||
|
|
||||||
const reactionMap = {};
|
const reactionMap = {};
|
||||||
for (const r of (msg.reactions || [])) {
|
for (const r of (msg.reactions || [])) {
|
||||||
@@ -66,6 +99,11 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
|||||||
setShowEmojiPicker(false);
|
setShowEmojiPicker(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (!msg.content) return;
|
||||||
|
navigator.clipboard.writeText(msg.content).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
const handleTogglePicker = () => {
|
const handleTogglePicker = () => {
|
||||||
if (!showEmojiPicker && wrapperRef.current) {
|
if (!showEmojiPicker && wrapperRef.current) {
|
||||||
// If the message is in the top 400px of viewport, open picker downward
|
// If the message is in the top 400px of viewport, open picker downward
|
||||||
@@ -97,11 +135,15 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
|||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
className={`message-wrapper ${isOwn ? 'own' : 'other'} ${prevSameUser ? 'grouped' : ''}`}
|
className={`message-wrapper ${isOwn ? 'own' : 'other'} ${prevSameUser ? 'grouped' : ''}`}
|
||||||
onMouseEnter={() => setShowActions(true)}
|
|
||||||
onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
|
|
||||||
>
|
>
|
||||||
{!isOwn && !prevSameUser && (
|
{!isOwn && !prevSameUser && (
|
||||||
<div ref={avatarRef} style={{ cursor: 'pointer' }} onClick={() => setShowProfile(p => !p)}>
|
<div
|
||||||
|
ref={avatarRef}
|
||||||
|
style={{ cursor: 'pointer', borderRadius: '50%', transition: 'box-shadow 0.15s' }}
|
||||||
|
onClick={() => setShowProfile(p => !p)}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
|
||||||
|
>
|
||||||
<Avatar user={msgUser} size="sm" className="msg-avatar" />
|
<Avatar user={msgUser} size="sm" className="msg-avatar" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -133,7 +175,10 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
|||||||
|
|
||||||
{/* Bubble + actions together so actions hover above bubble */}
|
{/* Bubble + actions together so actions hover above bubble */}
|
||||||
<div className="msg-bubble-wrap">
|
<div className="msg-bubble-wrap">
|
||||||
<div className="msg-bubble-with-actions">
|
<div className="msg-bubble-with-actions"
|
||||||
|
onMouseEnter={() => setShowActions(true)}
|
||||||
|
onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
|
||||||
|
>
|
||||||
{/* Actions toolbar — floats above the bubble, aligned to correct side */}
|
{/* Actions toolbar — floats above the bubble, aligned to correct side */}
|
||||||
{!isDeleted && (showActions || showEmojiPicker) && (
|
{!isDeleted && (showActions || showEmojiPicker) && (
|
||||||
<div className={`msg-actions ${isOwn ? 'actions-left' : 'actions-right'}`}>
|
<div className={`msg-actions ${isOwn ? 'actions-left' : 'actions-right'}`}>
|
||||||
@@ -146,6 +191,11 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
|||||||
<button className="btn-icon action-btn" onClick={() => onReply(msg)} title="Reply">
|
<button className="btn-icon action-btn" onClick={() => onReply(msg)} title="Reply">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
{msg.content && (
|
||||||
|
<button className="btn-icon action-btn" onClick={handleCopy} title="Copy text">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<button className="btn-icon action-btn danger" onClick={() => onDelete(msg.id)} title="Delete">
|
<button className="btn-icon action-btn danger" onClick={() => onDelete(msg.id)} title="Delete">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||||
@@ -165,7 +215,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`msg-bubble ${isOwn ? 'out' : 'in'}`}>
|
<div className={`msg-bubble ${isOwn ? 'out' : 'in'}${!msg.image_url && isEmojiOnly(msg.content) ? ' emoji-only' : ''}`}>
|
||||||
{msg.image_url && (
|
{msg.image_url && (
|
||||||
<img
|
<img
|
||||||
src={msg.image_url}
|
src={msg.image_url}
|
||||||
@@ -175,10 +225,12 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{msg.content && (
|
{msg.content && (
|
||||||
<p
|
isEmojiOnly(msg.content) && !msg.image_url
|
||||||
className="msg-text"
|
? <p className="msg-text emoji-msg">{msg.content}</p>
|
||||||
dangerouslySetInnerHTML={{ __html: formatMsgContent(msg.content) }}
|
: <p
|
||||||
/>
|
className="msg-text"
|
||||||
|
dangerouslySetInnerHTML={{ __html: formatMsgContent(msg.content) }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{msg.link_preview && <LinkPreview data={msg.link_preview} />}
|
{msg.link_preview && <LinkPreview data={msg.link_preview} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -209,6 +261,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
|||||||
user={msgUser}
|
user={msgUser}
|
||||||
anchorEl={avatarRef.current}
|
anchorEl={avatarRef.current}
|
||||||
onClose={() => setShowProfile(false)}
|
onClose={() => setShowProfile(false)}
|
||||||
|
onDirectMessage={onDirectMessage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{lightboxSrc && (
|
{lightboxSrc && (
|
||||||
@@ -236,11 +289,11 @@ function LinkPreview({ data: raw }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(dateStr) {
|
function formatTime(dateStr) {
|
||||||
return new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
return parseTS(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
const d = new Date(dateStr);
|
const d = parseTS(dateStr);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (d.toDateString() === now.toDateString()) return 'Today';
|
if (d.toDateString() === now.toDateString()) return 'Today';
|
||||||
const yest = new Date(now); yest.setDate(yest.getDate() - 1);
|
const yest = new Date(now); yest.setDate(yest.getDate() - 1);
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
background: white;
|
background: white;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-shrink: 0; /* never compress — always visible above keyboard */
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply-bar-input {
|
.reply-bar-input {
|
||||||
@@ -133,7 +137,7 @@
|
|||||||
.msg-input {
|
.msg-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
max-height: 120px;
|
max-height: calc(1.4em * 5 + 20px); /* 5 lines × line-height + padding */
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
@@ -143,9 +147,10 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--surface-variant);
|
background: var(--surface-variant);
|
||||||
transition: border-color var(--transition);
|
transition: border-color var(--transition);
|
||||||
overflow-y: auto;
|
overflow-y: hidden;
|
||||||
|
resize: none;
|
||||||
}
|
}
|
||||||
.msg-input:focus { outline: none; border-color: var(--primary); background: white; }
|
.msg-input:focus { outline: none; border-color: var(--primary); background: var(--surface-variant); }
|
||||||
.msg-input::placeholder { color: var(--text-tertiary); }
|
.msg-input::placeholder { color: var(--text-tertiary); }
|
||||||
|
|
||||||
.send-btn {
|
.send-btn {
|
||||||
@@ -166,3 +171,68 @@
|
|||||||
}
|
}
|
||||||
.send-btn.active:hover { background: var(--primary-dark); }
|
.send-btn.active:hover { background: var(--primary-dark); }
|
||||||
.send-btn:disabled { opacity: 0.4; cursor: default; }
|
.send-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
/* + attach button */
|
||||||
|
.attach-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attach-btn {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.attach-btn:hover {
|
||||||
|
color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attach menu popup */
|
||||||
|
.attach-menu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attach-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.attach-item:hover {
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.attach-item svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.attach-item:hover svg {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Emoji picker popover — positioned above the input area */
|
||||||
|
.emoji-input-picker {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PC only: enforce minimum width on the input row so send button never disappears */
|
||||||
|
@media (pointer: fine) {
|
||||||
|
.input-row {
|
||||||
|
min-width: 480px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { api } from '../utils/api.js';
|
import { api } from '../utils/api.js';
|
||||||
|
import data from '@emoji-mart/data';
|
||||||
|
import Picker from '@emoji-mart/react';
|
||||||
import './MessageInput.css';
|
import './MessageInput.css';
|
||||||
|
|
||||||
const URL_REGEX = /https?:\/\/[^\s]+/g;
|
const URL_REGEX = /https?:\/\/[^\s]+/g;
|
||||||
|
|
||||||
|
// Detect if a string is purely emoji characters (no other text)
|
||||||
|
function isEmojiOnly(str) {
|
||||||
|
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F|\u200D|[\u{1F1E0}-\u{1F1FF}])+$/u;
|
||||||
|
return emojiRegex.test(str.trim());
|
||||||
|
}
|
||||||
|
|
||||||
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping }) {
|
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping }) {
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [imageFile, setImageFile] = useState(null);
|
const [imageFile, setImageFile] = useState(null);
|
||||||
@@ -14,11 +22,30 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
|||||||
const [showMention, setShowMention] = useState(false);
|
const [showMention, setShowMention] = useState(false);
|
||||||
const [linkPreview, setLinkPreview] = useState(null);
|
const [linkPreview, setLinkPreview] = useState(null);
|
||||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||||
|
const [showAttachMenu, setShowAttachMenu] = useState(false);
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
const typingTimer = useRef(null);
|
const typingTimer = useRef(null);
|
||||||
const wasTyping = useRef(false);
|
const wasTyping = useRef(false);
|
||||||
const mentionStart = useRef(-1);
|
const mentionStart = useRef(-1);
|
||||||
const fileInput = useRef(null);
|
const fileInput = useRef(null);
|
||||||
|
const cameraInput = useRef(null);
|
||||||
|
const attachMenuRef = useRef(null);
|
||||||
|
const emojiPickerRef = useRef(null);
|
||||||
|
|
||||||
|
// Close attach menu / emoji picker on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => {
|
||||||
|
if (attachMenuRef.current && !attachMenuRef.current.contains(e.target)) {
|
||||||
|
setShowAttachMenu(false);
|
||||||
|
}
|
||||||
|
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target)) {
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Handle typing notification
|
// Handle typing notification
|
||||||
const handleTypingChange = (value) => {
|
const handleTypingChange = (value) => {
|
||||||
@@ -35,13 +62,26 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Link preview
|
// Link preview — 5 second timeout, then abandon and enable Send
|
||||||
|
const previewTimeoutRef = useRef(null);
|
||||||
|
|
||||||
const fetchPreview = useCallback(async (url) => {
|
const fetchPreview = useCallback(async (url) => {
|
||||||
setLoadingPreview(true);
|
setLoadingPreview(true);
|
||||||
|
setLinkPreview(null);
|
||||||
|
|
||||||
|
if (previewTimeoutRef.current) clearTimeout(previewTimeoutRef.current);
|
||||||
|
const abandonTimer = setTimeout(() => {
|
||||||
|
setLoadingPreview(false);
|
||||||
|
}, 5000);
|
||||||
|
previewTimeoutRef.current = abandonTimer;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { preview } = await api.getLinkPreview(url);
|
const { preview } = await api.getLinkPreview(url);
|
||||||
|
clearTimeout(abandonTimer);
|
||||||
if (preview) setLinkPreview(preview);
|
if (preview) setLinkPreview(preview);
|
||||||
} catch {}
|
} catch {
|
||||||
|
clearTimeout(abandonTimer);
|
||||||
|
}
|
||||||
setLoadingPreview(false);
|
setLoadingPreview(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -50,7 +90,13 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
|||||||
setText(val);
|
setText(val);
|
||||||
handleTypingChange(val);
|
handleTypingChange(val);
|
||||||
|
|
||||||
// Detect @mention
|
const el = e.target;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
const lineHeight = parseFloat(getComputedStyle(el).lineHeight);
|
||||||
|
const maxHeight = lineHeight * 5 + 20;
|
||||||
|
el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
|
||||||
|
el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
|
||||||
|
|
||||||
const cur = e.target.selectionStart;
|
const cur = e.target.selectionStart;
|
||||||
const lastAt = val.lastIndexOf('@', cur - 1);
|
const lastAt = val.lastIndexOf('@', cur - 1);
|
||||||
if (lastAt !== -1) {
|
if (lastAt !== -1) {
|
||||||
@@ -68,7 +114,6 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
|||||||
}
|
}
|
||||||
setShowMention(false);
|
setShowMention(false);
|
||||||
|
|
||||||
// Link preview
|
|
||||||
const urls = val.match(URL_REGEX);
|
const urls = val.match(URL_REGEX);
|
||||||
if (urls && urls[0] !== linkPreview?.url) {
|
if (urls && urls[0] !== linkPreview?.url) {
|
||||||
fetchPreview(urls[0]);
|
fetchPreview(urls[0]);
|
||||||
@@ -112,20 +157,33 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
|||||||
setImagePreview(null);
|
setImagePreview(null);
|
||||||
wasTyping.current = false;
|
wasTyping.current = false;
|
||||||
onTyping(false);
|
onTyping(false);
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.style.height = 'auto';
|
||||||
|
inputRef.current.style.overflowY = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
await onSend({ content: trimmed || null, imageFile, linkPreview: lp });
|
// Tag emoji-only messages so they can be rendered large
|
||||||
|
const emojiOnly = !!trimmed && isEmojiOnly(trimmed);
|
||||||
|
await onSend({ content: trimmed || null, imageFile, linkPreview: lp, emojiOnly });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send a single emoji directly (from picker)
|
||||||
|
const handleEmojiSend = async (emoji) => {
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
await onSend({ content: emoji.native, imageFile: null, linkPreview: null, emojiOnly: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const compressImage = (file) => new Promise((resolve) => {
|
const compressImage = (file) => new Promise((resolve) => {
|
||||||
const MAX_PX = 1920;
|
const MAX_PX = 1920;
|
||||||
const QUALITY = 0.82;
|
const QUALITY = 0.82;
|
||||||
|
const isPng = file.type === 'image/png';
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
let { width, height } = img;
|
let { width, height } = img;
|
||||||
if (width <= MAX_PX && height <= MAX_PX) {
|
if (width <= MAX_PX && height <= MAX_PX) {
|
||||||
// Already small enough — still re-encode to strip EXIF and reduce size
|
// already small
|
||||||
} else {
|
} else {
|
||||||
const ratio = Math.min(MAX_PX / width, MAX_PX / height);
|
const ratio = Math.min(MAX_PX / width, MAX_PX / height);
|
||||||
width = Math.round(width * ratio);
|
width = Math.round(width * ratio);
|
||||||
@@ -134,10 +192,17 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
|||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
|
const ctx = canvas.getContext('2d');
|
||||||
canvas.toBlob(blob => {
|
if (!isPng) {
|
||||||
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' }));
|
ctx.fillStyle = '#ffffff';
|
||||||
}, 'image/jpeg', QUALITY);
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
if (isPng) {
|
||||||
|
canvas.toBlob(blob => resolve(new File([blob], file.name, { type: 'image/png' })), 'image/png');
|
||||||
|
} else {
|
||||||
|
canvas.toBlob(blob => resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' })), 'image/jpeg', QUALITY);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
img.src = url;
|
img.src = url;
|
||||||
});
|
});
|
||||||
@@ -150,12 +215,11 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
|||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => setImagePreview(e.target.result);
|
reader.onload = (e) => setImagePreview(e.target.result);
|
||||||
reader.readAsDataURL(compressed);
|
reader.readAsDataURL(compressed);
|
||||||
|
setShowAttachMenu(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayText = (t) => {
|
// Detect mobile (touch device)
|
||||||
// Convert @[name](id) to @name for display
|
const isMobile = () => window.matchMedia('(pointer: coarse)').matches;
|
||||||
return t.replace(/@\[([^\]]+)\]\(\d+\)/g, '@$1');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message-input-area">
|
<div className="message-input-area">
|
||||||
@@ -215,10 +279,59 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="input-row">
|
<div className="input-row">
|
||||||
<button className="btn-icon input-action" onClick={() => fileInput.current?.click()} title="Attach image">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
{/* + button — attach menu trigger */}
|
||||||
</button>
|
<div className="attach-wrap" ref={attachMenuRef}>
|
||||||
|
<button
|
||||||
|
className="btn-icon input-action attach-btn"
|
||||||
|
onClick={() => { setShowAttachMenu(v => !v); setShowEmojiPicker(false); }}
|
||||||
|
title="Add photo or emoji"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="22" height="22">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAttachMenu && (
|
||||||
|
<div className="attach-menu">
|
||||||
|
{/* Photo from library */}
|
||||||
|
<button className="attach-item" onClick={() => fileInput.current?.click()}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||||
|
<span>Photo</span>
|
||||||
|
</button>
|
||||||
|
{/* Camera — mobile only */}
|
||||||
|
{isMobile() && (
|
||||||
|
<button className="attach-item" onClick={() => cameraInput.current?.click()}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
||||||
|
<span>Camera</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Emoji */}
|
||||||
|
<button className="attach-item" onClick={() => { setShowAttachMenu(false); setShowEmojiPicker(true); }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 13s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
||||||
|
<span>Emoji</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden file inputs */}
|
||||||
<input ref={fileInput} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleImageSelect} />
|
<input ref={fileInput} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleImageSelect} />
|
||||||
|
<input ref={cameraInput} type="file" accept="image/*" capture="environment" style={{ display: 'none' }} onChange={handleImageSelect} />
|
||||||
|
|
||||||
|
{/* Emoji picker popover */}
|
||||||
|
{showEmojiPicker && (
|
||||||
|
<div className="emoji-input-picker" ref={emojiPickerRef}>
|
||||||
|
<Picker
|
||||||
|
data={data}
|
||||||
|
onEmojiSelect={handleEmojiSend}
|
||||||
|
theme="light"
|
||||||
|
previewPosition="none"
|
||||||
|
skinTonePosition="none"
|
||||||
|
maxFrequentRows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="input-wrap">
|
<div className="input-wrap">
|
||||||
<textarea
|
<textarea
|
||||||
@@ -234,12 +347,15 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`send-btn ${(text.trim() || imageFile) ? 'active' : ''}`}
|
className={`send-btn ${(text.trim() || imageFile) && !loadingPreview ? 'active' : ''}`}
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!text.trim() && !imageFile}
|
disabled={(!text.trim() && !imageFile) || loadingPreview}
|
||||||
title="Send"
|
title={loadingPreview ? 'Loading preview…' : 'Send'}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
{loadingPreview
|
||||||
|
? <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||||
|
: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export default function NewChatModal({ onClose, onCreated }) {
|
|||||||
const [selected, setSelected] = useState([]);
|
const [selected, setSelected] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// True when exactly 1 user selected on private tab = direct message
|
||||||
|
const isDirect = tab === 'private' && selected.length === 1;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.searchUsers('').then(({ users }) => setUsers(users)).catch(() => {});
|
api.searchUsers('').then(({ users }) => setUsers(users)).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -25,18 +28,37 @@ export default function NewChatModal({ onClose, onCreated }) {
|
|||||||
}
|
}
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
|
const toggle = (u) => {
|
||||||
|
if (u.id === user.id) return;
|
||||||
|
setSelected(prev => prev.find(p => p.id === u.id) ? prev.filter(p => p.id !== u.id) : [...prev, u]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!name.trim()) return toast('Name required', 'error');
|
|
||||||
if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error');
|
if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error');
|
||||||
|
if (tab === 'private' && selected.length > 1 && !name.trim()) return toast('Name required', 'error');
|
||||||
|
if (tab === 'public' && !name.trim()) return toast('Name required', 'error');
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { group } = await api.createGroup({
|
let payload;
|
||||||
name: name.trim(),
|
if (isDirect) {
|
||||||
type: tab,
|
// Direct message: no name, isDirect flag
|
||||||
memberIds: selected.map(u => u.id),
|
payload = {
|
||||||
isReadonly: tab === 'public' && isReadonly,
|
type: 'private',
|
||||||
});
|
memberIds: selected.map(u => u.id),
|
||||||
toast(`${tab === 'public' ? 'Channel' : 'Chat'} created!`, 'success');
|
isDirect: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
payload = {
|
||||||
|
name: name.trim(),
|
||||||
|
type: tab,
|
||||||
|
memberIds: selected.map(u => u.id),
|
||||||
|
isReadonly: tab === 'public' && isReadonly,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { group } = await api.createGroup(payload);
|
||||||
|
toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success');
|
||||||
onCreated(group);
|
onCreated(group);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, 'error');
|
toast(e.message, 'error');
|
||||||
@@ -45,10 +67,10 @@ export default function NewChatModal({ onClose, onCreated }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggle = (u) => {
|
// Placeholder for the name field
|
||||||
if (u.id === user.id) return;
|
const namePlaceholder = isDirect
|
||||||
setSelected(prev => prev.find(p => p.id === u.id) ? prev.filter(p => p.id !== u.id) : [...prev, u]);
|
? selected[0]?.name || ''
|
||||||
};
|
: tab === 'public' ? 'e.g. Announcements' : 'e.g. Project Team';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
@@ -62,49 +84,66 @@ export default function NewChatModal({ onClose, onCreated }) {
|
|||||||
|
|
||||||
{user.role === 'admin' && (
|
{user.role === 'admin' && (
|
||||||
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
||||||
<button className={`btn ${tab === 'private' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('private')}>Private Group</button>
|
<button className={`btn ${tab === 'private' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('private')}>Direct Message</button>
|
||||||
<button className={`btn ${tab === 'public' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('public')}>Public Channel</button>
|
<button className={`btn ${tab === 'public' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('public')}>Public Message</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-col gap-2" style={{ marginBottom: 16 }}>
|
{/* Message Name — hidden for direct (1-user) messages */}
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
{!isDirect && (
|
||||||
{tab === 'public' ? 'Channel Name' : 'Group Name'}
|
<div className="flex-col gap-2" style={{ marginBottom: 16 }}>
|
||||||
</label>
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message Name</label>
|
||||||
<input className="input" value={name} onChange={e => setName(e.target.value)} placeholder={tab === 'public' ? 'e.g. Announcements' : 'e.g. Project Team'} autoFocus />
|
<input
|
||||||
</div>
|
className="input"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder={namePlaceholder}
|
||||||
|
autoFocus={tab === 'public'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Readonly toggle for public */}
|
||||||
{tab === 'public' && user.role === 'admin' && (
|
{tab === 'public' && user.role === 'admin' && (
|
||||||
<label className="flex items-center gap-2 text-sm" style={{ marginBottom: 16, cursor: 'pointer', color: 'var(--text-secondary)' }}>
|
<label className="flex items-center gap-2 text-sm" style={{ marginBottom: 16, cursor: 'pointer', color: 'var(--text-secondary)' }}>
|
||||||
<input type="checkbox" checked={isReadonly} onChange={e => setIsReadonly(e.target.checked)} />
|
<input type="checkbox" checked={isReadonly} onChange={e => setIsReadonly(e.target.checked)} />
|
||||||
Read-only channel (only admins can post)
|
Read-only message (only admins can post)
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Member selector for private tab */}
|
||||||
{tab === 'private' && (
|
{tab === 'private' && (
|
||||||
<>
|
<>
|
||||||
<div className="flex-col gap-2" style={{ marginBottom: 12 }}>
|
<div className="flex-col gap-2" style={{ marginBottom: 12 }}>
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Add Members</label>
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<input className="input" placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} />
|
{isDirect ? 'Direct Message with' : 'Add Members'}
|
||||||
|
</label>
|
||||||
|
<input className="input" placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} autoFocus />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selected.length > 0 && (
|
{selected.length > 0 && (
|
||||||
<div className="flex gap-2" style={{ flexWrap: 'wrap', marginBottom: 12 }}>
|
<div className="flex gap-2" style={{ flexWrap: 'wrap', marginBottom: 12 }}>
|
||||||
{selected.map(u => (
|
{selected.map(u => (
|
||||||
<span key={u.id} className="chip">
|
<span key={u.id} className="chip">
|
||||||
{u.display_name || u.name}
|
{u.name}
|
||||||
<span className="chip-remove" onClick={() => toggle(u)}>×</span>
|
<span className="chip-remove" onClick={() => toggle(u)}>×</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isDirect && (
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 12, fontStyle: 'italic' }}>
|
||||||
|
A private two-person conversation. Select a second person to create a group instead.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
<div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
||||||
{users.filter(u => u.id !== user.id).map(u => (
|
{users.filter(u => u.id !== user.id).map(u => (
|
||||||
<label key={u.id} className="flex items-center gap-10 pointer" style={{ padding: '10px 14px', gap: 12, borderBottom: '1px solid var(--border)', cursor: 'pointer' }}>
|
<label key={u.id} className="flex items-center gap-10 pointer" style={{ padding: '10px 14px', gap: 12, borderBottom: '1px solid var(--border)', cursor: 'pointer' }}>
|
||||||
<input type="checkbox" checked={!!selected.find(s => s.id === u.id)} onChange={() => toggle(u)} />
|
<input type="checkbox" checked={!!selected.find(s => s.id === u.id)} onChange={() => toggle(u)} />
|
||||||
<Avatar user={u} size="sm" />
|
<Avatar user={u} size="sm" />
|
||||||
<span className="flex-1 text-sm">{u.display_name || u.name}</span>
|
<span className="flex-1 text-sm">{u.name}</span>
|
||||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{u.role}</span>
|
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{u.role}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
@@ -115,7 +154,7 @@ export default function NewChatModal({ onClose, onCreated }) {
|
|||||||
<div className="flex gap-2 justify-between" style={{ marginTop: 20 }}>
|
<div className="flex gap-2 justify-between" style={{ marginTop: 20 }}>
|
||||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||||
<button className="btn btn-primary" onClick={handleCreate} disabled={loading}>
|
<button className="btn btn-primary" onClick={handleCreate} disabled={loading}>
|
||||||
{loading ? 'Creating...' : 'Create'}
|
{loading ? 'Creating...' : isDirect ? 'Start Conversation' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default function ProfileModal({ onClose }) {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [displayName, setDisplayName] = useState(user?.display_name || '');
|
const [displayName, setDisplayName] = useState(user?.display_name || '');
|
||||||
|
const [displayNameWarning, setDisplayNameWarning] = useState('');
|
||||||
const [aboutMe, setAboutMe] = useState(user?.about_me || '');
|
const [aboutMe, setAboutMe] = useState(user?.about_me || '');
|
||||||
const [currentPw, setCurrentPw] = useState('');
|
const [currentPw, setCurrentPw] = useState('');
|
||||||
const [newPw, setNewPw] = useState('');
|
const [newPw, setNewPw] = useState('');
|
||||||
@@ -18,6 +19,7 @@ export default function ProfileModal({ onClose }) {
|
|||||||
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
|
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
|
if (displayNameWarning) return toast('Display name is already in use', 'error');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag });
|
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag });
|
||||||
@@ -98,7 +100,24 @@ export default function ProfileModal({ onClose }) {
|
|||||||
<div className="flex-col gap-3">
|
<div className="flex-col gap-3">
|
||||||
<div className="flex-col gap-1">
|
<div className="flex-col gap-1">
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Display Name</label>
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Display Name</label>
|
||||||
<input className="input" value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder={user?.name} />
|
<input
|
||||||
|
className="input"
|
||||||
|
value={displayName}
|
||||||
|
onChange={async e => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setDisplayName(val);
|
||||||
|
setDisplayNameWarning('');
|
||||||
|
if (val && val !== user?.display_name) {
|
||||||
|
try {
|
||||||
|
const { taken } = await api.checkDisplayName(val);
|
||||||
|
if (taken) setDisplayNameWarning('Display name is already in use');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={user?.name}
|
||||||
|
style={{ borderColor: displayNameWarning ? '#e53935' : undefined }}
|
||||||
|
/>
|
||||||
|
{displayNameWarning && <span className="text-xs" style={{ color: '#e53935' }}>{displayNameWarning}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-col gap-1">
|
<div className="flex-col gap-1">
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>About Me</label>
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>About Me</label>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
width: 320px;
|
width: 320px;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
background: white;
|
background: white;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
@@ -17,8 +19,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 16px 16px 12px;
|
padding: 14px 16px 12px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-title {
|
.sidebar-title {
|
||||||
@@ -28,42 +31,68 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.offline-dot {
|
.offline-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #e53935;
|
background: #e53935;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-search {
|
/* New Chat bar (desktop) */
|
||||||
padding: 12px 12px 8px;
|
.sidebar-newchat-bar {
|
||||||
|
padding: 10px 12px 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-wrap {
|
.newchat-btn {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
gap: 8px;
|
||||||
|
|
||||||
.search-icon {
|
|
||||||
position: absolute;
|
|
||||||
left: 12px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 12px 8px 36px;
|
padding: 9px 16px;
|
||||||
border: none;
|
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: var(--background);
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-primary);
|
font-weight: 600;
|
||||||
font-family: var(--font);
|
border: 1.5px solid var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition), color var(--transition);
|
||||||
}
|
}
|
||||||
.search-input:focus { outline: none; }
|
.newchat-btn:hover { background: var(--primary); color: white; }
|
||||||
.search-input::placeholder { color: var(--text-tertiary); }
|
.newchat-btn:hover svg { stroke: white; }
|
||||||
|
|
||||||
|
/* Mobile FAB */
|
||||||
|
.newchat-fab {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 80px;
|
||||||
|
right: 16px;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
||||||
|
z-index: 10;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition), transform 80ms ease;
|
||||||
|
border: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.newchat-fab:hover { transform: scale(1.05); }
|
||||||
|
.newchat-fab:active { transform: scale(0.97); }
|
||||||
|
|
||||||
.groups-list {
|
.groups-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -88,7 +117,6 @@
|
|||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
border-radius: 0;
|
|
||||||
}
|
}
|
||||||
.group-item:hover { background: var(--background); }
|
.group-item:hover { background: var(--background); }
|
||||||
.group-item.active { background: var(--primary-light); }
|
.group-item.active { background: var(--primary-light); }
|
||||||
@@ -131,11 +159,12 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding: 8px;
|
padding: 12px 8px;
|
||||||
|
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-footer-btn {
|
.user-footer-btn {
|
||||||
@@ -179,24 +208,8 @@
|
|||||||
.footer-menu-item.danger { color: var(--error); }
|
.footer-menu-item.danger { color: var(--error); }
|
||||||
.footer-menu-item.danger:hover { background: #fce8e6; }
|
.footer-menu-item.danger:hover { background: #fce8e6; }
|
||||||
|
|
||||||
/* App logo in sidebar header */
|
.group-item.has-unread { background: var(--primary-light); }
|
||||||
.sidebar-logo {
|
.unread-name { font-weight: 700; color: var(--text-primary) !important; }
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: 4px;
|
|
||||||
object-fit: cover;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Unread message indicator */
|
|
||||||
.group-item.has-unread {
|
|
||||||
background: var(--primary-light);
|
|
||||||
}
|
|
||||||
.unread-name {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
.badge-unread {
|
.badge-unread {
|
||||||
background: var(--text-secondary);
|
background: var(--text-secondary);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
import { useSocket } from '../contexts/SocketContext.jsx';
|
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||||
import { api } from '../utils/api.js';
|
import { api, parseTS } from '../utils/api.js';
|
||||||
import { useToast } from '../contexts/ToastContext.jsx';
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
import Avatar from './Avatar.jsx';
|
import Avatar from './Avatar.jsx';
|
||||||
import './Sidebar.css';
|
import './Sidebar.css';
|
||||||
@@ -26,41 +26,50 @@ function useAppSettings() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
// Re-fetch when settings are saved from the SettingsModal
|
|
||||||
window.addEventListener('jama:settings-changed', fetchSettings);
|
window.addEventListener('jama:settings-changed', fetchSettings);
|
||||||
return () => window.removeEventListener('jama:settings-changed', fetchSettings);
|
return () => window.removeEventListener('jama:settings-changed', fetchSettings);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Update page title and favicon whenever settings change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const name = settings.app_name || 'jama';
|
const name = settings.app_name || 'jama';
|
||||||
|
|
||||||
// Update <title>
|
|
||||||
document.title = name;
|
document.title = name;
|
||||||
|
|
||||||
// Update favicon
|
|
||||||
const logoUrl = settings.logo_url;
|
const logoUrl = settings.logo_url;
|
||||||
const faviconUrl = logoUrl || '/icons/jama.png';
|
const faviconUrl = logoUrl || '/icons/jama.png';
|
||||||
let link = document.querySelector("link[rel~='icon']");
|
let link = document.querySelector("link[rel~='icon']");
|
||||||
if (!link) {
|
if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
|
||||||
link = document.createElement('link');
|
|
||||||
link.rel = 'icon';
|
|
||||||
document.head.appendChild(link);
|
|
||||||
}
|
|
||||||
link.href = faviconUrl;
|
link.href = faviconUrl;
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated }) {
|
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated, isMobile, onAbout }) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const { connected } = useSocket();
|
const { connected } = useSocket();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const settings = useAppSettings();
|
const settings = useAppSettings();
|
||||||
const [dark, setDark] = useTheme();
|
const [dark, setDark] = useTheme();
|
||||||
|
const menuRef = useRef(null);
|
||||||
|
const footerBtnRef = useRef(null);
|
||||||
|
|
||||||
|
// Fix 6: swipe right to go back on mobile — handled in ChatWindow, but prevent sidebar swipe exit
|
||||||
|
// Close menu on click outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showMenu) return;
|
||||||
|
const handler = (e) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target) &&
|
||||||
|
footerBtnRef.current && !footerBtnRef.current.contains(e.target)) {
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
document.addEventListener('touchstart', handler);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handler);
|
||||||
|
document.removeEventListener('touchstart', handler);
|
||||||
|
};
|
||||||
|
}, [showMenu]);
|
||||||
|
|
||||||
const appName = settings.app_name || 'jama';
|
const appName = settings.app_name || 'jama';
|
||||||
const logoUrl = settings.logo_url;
|
const logoUrl = settings.logo_url;
|
||||||
@@ -70,18 +79,12 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
...(groups.privateGroups || [])
|
...(groups.privateGroups || [])
|
||||||
];
|
];
|
||||||
|
|
||||||
const filtered = search
|
const publicFiltered = allGroups.filter(g => g.type === 'public');
|
||||||
? allGroups.filter(g => g.name.toLowerCase().includes(search.toLowerCase()))
|
const privateFiltered = allGroups.filter(g => g.type === 'private');
|
||||||
: allGroups;
|
|
||||||
|
|
||||||
const publicFiltered = filtered.filter(g => g.type === 'public');
|
|
||||||
const privateFiltered = filtered.filter(g => g.type === 'private');
|
|
||||||
|
|
||||||
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
|
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => { await logout(); };
|
||||||
await logout();
|
|
||||||
};
|
|
||||||
|
|
||||||
const GroupItem = ({ group }) => {
|
const GroupItem = ({ group }) => {
|
||||||
const notifs = getNotifCount(group.id);
|
const notifs = getNotifCount(group.id);
|
||||||
@@ -115,36 +118,16 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar">
|
<div className="sidebar">
|
||||||
{/* Header with live app name and logo */}
|
{/* New Chat button replacing search bar */}
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-newchat-bar">
|
||||||
<div className="flex items-center gap-2 flex-1" style={{ minWidth: 0 }}>
|
{!isMobile && (
|
||||||
{logoUrl ? (
|
<button className="newchat-btn" onClick={onNewChat}>
|
||||||
<img src={logoUrl} alt={appName} className="sidebar-logo" />
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="18" height="18">
|
||||||
) : (
|
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||||
<img src="/icons/jama.png" alt="jama" className="sidebar-logo" />
|
|
||||||
)}
|
|
||||||
<h2 className="sidebar-title truncate">{appName}</h2>
|
|
||||||
{!connected && <span className="offline-dot" title="Offline" />}
|
|
||||||
</div>
|
|
||||||
<button className="btn-icon" onClick={onNewChat} title="New Chat">
|
|
||||||
{settings.icon_newchat ? (
|
|
||||||
<img src={settings.icon_newchat} alt="New Chat" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
|
||||||
) : (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="30" height="30">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
New Chat
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<div className="sidebar-search">
|
|
||||||
<div className="search-wrap">
|
|
||||||
<svg className="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
||||||
</svg>
|
|
||||||
<input className="search-input" placeholder="Search chats..." value={search} onChange={e => setSearch(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Groups list */}
|
{/* Groups list */}
|
||||||
@@ -155,25 +138,32 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
{publicFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
{publicFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{privateFiltered.length > 0 && (
|
{privateFiltered.length > 0 && (
|
||||||
<div className="group-section">
|
<div className="group-section">
|
||||||
<div className="section-label">DIRECT MESSAGES</div>
|
<div className="section-label">DIRECT MESSAGES</div>
|
||||||
{privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
{privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{allGroups.length === 0 && (
|
||||||
{filtered.length === 0 && (
|
|
||||||
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-tertiary)', fontSize: 14 }}>
|
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-tertiary)', fontSize: 14 }}>
|
||||||
No chats found
|
No chats yet
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile FAB: New Chat button floats above user footer */}
|
||||||
|
{isMobile && (
|
||||||
|
<button className="newchat-fab" onClick={onNewChat} title="New Chat">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="24" height="24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User footer */}
|
{/* User footer */}
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<button className="user-footer-btn" style={{ flex: 1 }} onClick={() => setShowMenu(!showMenu)}>
|
<button ref={footerBtnRef} className="user-footer-btn" style={{ flex: 1 }} onClick={() => setShowMenu(!showMenu)}>
|
||||||
<Avatar user={user} size="sm" />
|
<Avatar user={user} size="sm" />
|
||||||
<div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}>
|
<div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}>
|
||||||
<span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span>
|
<span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span>
|
||||||
@@ -190,7 +180,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
style={{ flexShrink: 0, padding: 8 }}
|
style={{ flexShrink: 0, padding: 8 }}
|
||||||
>
|
>
|
||||||
{dark ? (
|
{dark ? (
|
||||||
/* Sun icon — click to go light */
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<circle cx="12" cy="12" r="5"/>
|
<circle cx="12" cy="12" r="5"/>
|
||||||
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
||||||
@@ -199,7 +188,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
/* Moon icon — click to go dark */
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -208,24 +196,29 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showMenu && (
|
{showMenu && (
|
||||||
<div className="footer-menu" onClick={() => setShowMenu(false)}>
|
<div ref={menuRef} className="footer-menu">
|
||||||
<button className="footer-menu-item" onClick={onProfile}>
|
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onProfile(); }}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
Profile
|
Profile
|
||||||
</button>
|
</button>
|
||||||
{user?.role === 'admin' && (
|
{user?.role === 'admin' && (
|
||||||
<>
|
<>
|
||||||
<button className="footer-menu-item" onClick={onUsers}>
|
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onUsers(); }}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
User Manager
|
User Manager
|
||||||
</button>
|
</button>
|
||||||
<button className="footer-menu-item" onClick={onOpenSettings}>
|
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onOpenSettings(); }}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<hr className="divider" style={{ margin: '4px 0' }} />
|
<hr className="divider" style={{ margin: '4px 0' }} />
|
||||||
|
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onAbout && onAbout(); }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
|
About
|
||||||
|
</button>
|
||||||
|
<hr className="divider" style={{ margin: '4px 0' }} />
|
||||||
<button className="footer-menu-item danger" onClick={handleLogout}>
|
<button className="footer-menu-item danger" onClick={handleLogout}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||||
Sign out
|
Sign out
|
||||||
@@ -239,7 +232,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
|
|
||||||
function formatTime(dateStr) {
|
function formatTime(dateStr) {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
const date = new Date(dateStr);
|
const date = parseTS(dateStr);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = now - date;
|
const diff = now - date;
|
||||||
if (diff < 86400000 && date.getDate() === now.getDate()) {
|
if (diff < 86400000 && date.getDate() === now.getDate()) {
|
||||||
@@ -250,3 +243,4 @@ function formatTime(dateStr) {
|
|||||||
}
|
}
|
||||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,229 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
|
||||||
import { useToast } from '../contexts/ToastContext.jsx';
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
import { api } from '../utils/api.js';
|
import { api } from '../utils/api.js';
|
||||||
import Avatar from './Avatar.jsx';
|
import Avatar from './Avatar.jsx';
|
||||||
import Papa from 'papaparse';
|
|
||||||
|
function isValidEmail(email) {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCSV(text) {
|
||||||
|
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||||||
|
const rows = [], invalid = [];
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (i === 0 && /^name\s*,/i.test(line)) continue;
|
||||||
|
const parts = line.split(',').map(p => p.trim());
|
||||||
|
if (parts.length < 2 || parts.length > 4) { invalid.push({ line, reason: 'Must have 2–4 comma-separated fields' }); continue; }
|
||||||
|
const [name, email, password, role] = parts;
|
||||||
|
if (!name || !/\S+\s+\S+/.test(name)) { invalid.push({ line, reason: 'Name must be two words (First Last)' }); continue; }
|
||||||
|
if (!email || !isValidEmail(email)) { invalid.push({ line, reason: `Invalid email: "${email}"` }); continue; }
|
||||||
|
rows.push({ name: name.trim(), email: email.trim().toLowerCase(), password: (password || '').trim(), role: (role || 'member').trim().toLowerCase() });
|
||||||
|
}
|
||||||
|
return { rows, invalid };
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserRow({ u, onUpdated }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [resetPw, setResetPw] = useState('');
|
||||||
|
const [showReset, setShowReset] = useState(false);
|
||||||
|
const [editName, setEditName] = useState(false);
|
||||||
|
const [nameVal, setNameVal] = useState(u.name);
|
||||||
|
const [roleWarning, setRoleWarning] = useState(false);
|
||||||
|
|
||||||
|
const handleRole = async (role) => {
|
||||||
|
if (!role) { setRoleWarning(true); return; }
|
||||||
|
setRoleWarning(false);
|
||||||
|
try { await api.updateRole(u.id, role); toast('Role updated', 'success'); onUpdated(); }
|
||||||
|
catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPw = async () => {
|
||||||
|
if (!resetPw || resetPw.length < 6) return toast('Min 6 characters', 'error');
|
||||||
|
try { await api.resetPassword(u.id, resetPw); toast('Password reset', 'success'); setShowReset(false); setResetPw(''); onUpdated(); }
|
||||||
|
catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveName = async () => {
|
||||||
|
if (!nameVal.trim()) return toast('Name cannot be empty', 'error');
|
||||||
|
try {
|
||||||
|
const { name } = await api.updateName(u.id, nameVal.trim());
|
||||||
|
toast(name !== nameVal.trim() ? `Saved as "${name}"` : 'Name updated', 'success');
|
||||||
|
setEditName(false); onUpdated();
|
||||||
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuspend = async () => {
|
||||||
|
if (!confirm(`Suspend ${u.name}?`)) return;
|
||||||
|
try { await api.suspendUser(u.id); toast('User suspended', 'success'); onUpdated(); }
|
||||||
|
catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivate = async () => {
|
||||||
|
try { await api.activateUser(u.id); toast('User activated', 'success'); onUpdated(); }
|
||||||
|
catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (u.role === 'admin') return toast('Demote to member before deleting an admin', 'error');
|
||||||
|
if (!confirm(`Delete ${u.name}? Their messages will remain but they cannot log in.`)) return;
|
||||||
|
try { await api.deleteUser(u.id); toast('User deleted', 'success'); onUpdated(); }
|
||||||
|
catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
{/* Row header — always visible */}
|
||||||
|
<button
|
||||||
|
onClick={() => { setOpen(o => !o); setShowReset(false); setEditName(false); }}
|
||||||
|
style={{
|
||||||
|
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
padding: '10px 4px', background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
textAlign: 'left', color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar user={u} size="sm" />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14 }}>{u.name}</span>
|
||||||
|
<span className={`role-badge role-${u.role}`}>{u.role}</span>
|
||||||
|
{u.status !== 'active' && <span className="role-badge status-suspended">{u.status}</span>}
|
||||||
|
{!!u.is_default_admin && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Default Admin</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.email}</div>
|
||||||
|
{!!u.must_change_password && <div className="text-xs" style={{ color: 'var(--warning)' }}>⚠ Must change password</div>}
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||||
|
style={{ flexShrink: 0, transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none', color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Accordion panel */}
|
||||||
|
{open && !u.is_default_admin && (
|
||||||
|
<div style={{ padding: '4px 4px 14px 44px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
|
||||||
|
{/* Edit name */}
|
||||||
|
{editName ? (
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
style={{ flex: 1, fontSize: 13, padding: '5px 8px' }}
|
||||||
|
value={nameVal}
|
||||||
|
onChange={e => setNameVal(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditName(false); setNameVal(u.name); } }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={handleSaveName}>Save</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => { setEditName(false); setNameVal(u.name); }}>✕</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
||||||
|
onClick={() => { setEditName(true); setShowReset(false); }}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
Edit Name
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role selector */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 4 }}>
|
||||||
|
<select
|
||||||
|
value={roleWarning ? '' : u.role}
|
||||||
|
onChange={e => handleRole(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
style={{ width: 140, padding: '5px 8px', fontSize: 13, borderColor: roleWarning ? '#e53935' : undefined }}
|
||||||
|
>
|
||||||
|
<option value="" disabled>User Role</option>
|
||||||
|
<option value="member">Member</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
{roleWarning && <span style={{ fontSize: 12, color: '#e53935' }}>Role Required</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset password */}
|
||||||
|
{showReset ? (
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
style={{ flex: 1, fontSize: 13, padding: '5px 8px' }}
|
||||||
|
type="text"
|
||||||
|
placeholder="New password (min 6)"
|
||||||
|
value={resetPw}
|
||||||
|
onChange={e => setResetPw(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleResetPw(); if (e.key === 'Escape') { setShowReset(false); setResetPw(''); } }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={handleResetPw}>Set</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => { setShowReset(false); setResetPw(''); }}>✕</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
||||||
|
onClick={() => { setShowReset(true); setEditName(false); }}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||||
|
Reset Password
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suspend / Activate / Delete */}
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{u.status === 'active' ? (
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={handleSuspend}>Suspend</button>
|
||||||
|
) : u.status === 'suspended' ? (
|
||||||
|
<button className="btn btn-secondary btn-sm" style={{ color: 'var(--success)' }} onClick={handleActivate}>Activate</button>
|
||||||
|
) : null}
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={handleDelete}>Delete User</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function UserManagerModal({ onClose }) {
|
export default function UserManagerModal({ onClose }) {
|
||||||
const { user: me } = useAuth();
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [tab, setTab] = useState('users'); // 'users' | 'create' | 'bulk'
|
const [tab, setTab] = useState('users');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [form, setForm] = useState({ name: '', email: '', password: '', role: 'member' });
|
const [form, setForm] = useState({ name: '', email: '', password: '', role: 'member' });
|
||||||
const [bulkPreview, setBulkPreview] = useState([]);
|
|
||||||
|
const [csvFile, setCsvFile] = useState(null);
|
||||||
|
const [csvRows, setCsvRows] = useState([]);
|
||||||
|
const [csvInvalid, setCsvInvalid] = useState([]);
|
||||||
|
const [bulkResult, setBulkResult] = useState(null);
|
||||||
const [bulkLoading, setBulkLoading] = useState(false);
|
const [bulkLoading, setBulkLoading] = useState(false);
|
||||||
const [resetingId, setResetingId] = useState(null);
|
|
||||||
const [resetPw, setResetPw] = useState('');
|
|
||||||
const fileRef = useRef(null);
|
const fileRef = useRef(null);
|
||||||
|
const [userPass, setUserPass] = useState('user@1234');
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
api.getUsers().then(({ users }) => setUsers(users)).catch(() => {}).finally(() => setLoading(false));
|
api.getUsers().then(({ users }) => setUsers(users)).catch(() => {}).finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
useEffect(() => {
|
||||||
useEffect(() => { load(); }, []);
|
load();
|
||||||
|
api.getSettings().then(({ settings }) => {
|
||||||
|
if (settings.user_pass) setUserPass(settings.user_pass);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const filtered = users.filter(u =>
|
const filtered = users.filter(u =>
|
||||||
!search || u.name?.toLowerCase().includes(search.toLowerCase()) || u.email?.toLowerCase().includes(search.toLowerCase())
|
!search || u.name?.toLowerCase().includes(search.toLowerCase()) || u.email?.toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!form.name || !form.email || !form.password) return toast('All fields required', 'error');
|
if (!form.name.trim() || !form.email.trim()) return toast('Name and email are required', 'error');
|
||||||
|
if (!isValidEmail(form.email)) return toast('Invalid email address', 'error');
|
||||||
|
if (!/\S+\s+\S+/.test(form.name.trim())) return toast('Name must be two words (First Last)', 'error');
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
await api.createUser(form);
|
await api.createUser(form);
|
||||||
@@ -46,31 +238,28 @@ export default function UserManagerModal({ onClose }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCSV = (e) => {
|
const handleFileSelect = (e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
Papa.parse(file, {
|
setCsvFile(file);
|
||||||
header: true,
|
setBulkResult(null);
|
||||||
complete: ({ data }) => {
|
const reader = new FileReader();
|
||||||
const rows = data.filter(r => r.email).map(r => ({
|
reader.onload = (ev) => {
|
||||||
name: r.name || r.Name || '',
|
const { rows, invalid } = parseCSV(ev.target.result);
|
||||||
email: r.email || r.Email || '',
|
setCsvRows(rows);
|
||||||
password: r.password || r.Password || 'TempPass@123',
|
setCsvInvalid(invalid);
|
||||||
role: (r.role || r.Role || 'member').toLowerCase(),
|
};
|
||||||
}));
|
reader.readAsText(file);
|
||||||
setBulkPreview(rows);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkImport = async () => {
|
const handleBulkImport = async () => {
|
||||||
if (!bulkPreview.length) return;
|
if (!csvRows.length) return;
|
||||||
setBulkLoading(true);
|
setBulkLoading(true);
|
||||||
try {
|
try {
|
||||||
const results = await api.bulkUsers(bulkPreview);
|
const result = await api.bulkUsers(csvRows);
|
||||||
toast(`Created: ${results.created.length}, Errors: ${results.errors.length}`, results.errors.length ? 'default' : 'success');
|
setBulkResult(result);
|
||||||
setBulkPreview([]);
|
setCsvRows([]); setCsvFile(null); setCsvInvalid([]);
|
||||||
setTab('users');
|
if (fileRef.current) fileRef.current.value = '';
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, 'error');
|
toast(e.message, 'error');
|
||||||
@@ -79,48 +268,9 @@ export default function UserManagerModal({ onClose }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRole = async (u, role) => {
|
|
||||||
try {
|
|
||||||
await api.updateRole(u.id, role);
|
|
||||||
toast('Role updated', 'success');
|
|
||||||
load();
|
|
||||||
} catch (e) {
|
|
||||||
toast(e.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetPw = async (uid) => {
|
|
||||||
if (!resetPw || resetPw.length < 6) return toast('Enter a password (min 6 chars)', 'error');
|
|
||||||
try {
|
|
||||||
await api.resetPassword(uid, resetPw);
|
|
||||||
toast('Password reset — user must change on next login', 'success');
|
|
||||||
setResetingId(null);
|
|
||||||
setResetPw('');
|
|
||||||
} catch (e) {
|
|
||||||
toast(e.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSuspend = async (u) => {
|
|
||||||
if (!confirm(`Suspend ${u.name}?`)) return;
|
|
||||||
try { await api.suspendUser(u.id); toast('User suspended', 'success'); load(); }
|
|
||||||
catch (e) { toast(e.message, 'error'); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActivate = async (u) => {
|
|
||||||
try { await api.activateUser(u.id); toast('User activated', 'success'); load(); }
|
|
||||||
catch (e) { toast(e.message, 'error'); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (u) => {
|
|
||||||
if (!confirm(`Delete ${u.name}? Their messages will remain but they cannot log in.`)) return;
|
|
||||||
try { await api.deleteUser(u.id); toast('User deleted', 'success'); load(); }
|
|
||||||
catch (e) { toast(e.message, 'error'); }
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
<div className="modal" style={{ maxWidth: 700 }}>
|
<div className="modal" style={{ maxWidth: 600, width: '100%' }}>
|
||||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
<h2 className="modal-title" style={{ margin: 0 }}>User Manager</h2>
|
<h2 className="modal-title" style={{ margin: 0 }}>User Manager</h2>
|
||||||
<button className="btn-icon" onClick={onClose}>
|
<button className="btn-icon" onClick={onClose}>
|
||||||
@@ -128,78 +278,22 @@ export default function UserManagerModal({ onClose }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
||||||
<button className={`btn btn-sm ${tab === 'users' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('users')}>
|
<button className={`btn btn-sm ${tab === 'users' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('users')}>All Users ({users.length})</button>
|
||||||
All Users ({users.length})
|
<button className={`btn btn-sm ${tab === 'create' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('create')}>+ Create User</button>
|
||||||
</button>
|
<button className={`btn btn-sm ${tab === 'bulk' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('bulk')}>Bulk Import CSV</button>
|
||||||
<button className={`btn btn-sm ${tab === 'create' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('create')}>
|
|
||||||
+ Create User
|
|
||||||
</button>
|
|
||||||
<button className={`btn btn-sm ${tab === 'bulk' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('bulk')}>
|
|
||||||
Bulk Import CSV
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Users list */}
|
{/* Users list — accordion */}
|
||||||
{tab === 'users' && (
|
{tab === 'users' && (
|
||||||
<>
|
<>
|
||||||
<input className="input" style={{ marginBottom: 12 }} placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} />
|
<input className="input" style={{ marginBottom: 12 }} placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center" style={{ padding: 40 }}><div className="spinner" /></div>
|
<div className="flex justify-center" style={{ padding: 40 }}><div className="spinner" /></div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ maxHeight: 440, overflowY: 'auto' }}>
|
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||||
{filtered.map(u => (
|
{filtered.map(u => (
|
||||||
<div key={u.id} style={{ borderBottom: '1px solid var(--border)', padding: '12px 0' }}>
|
<UserRow key={u.id} u={u} onUpdated={load} />
|
||||||
<div className="flex items-center gap-2" style={{ gap: 12 }}>
|
|
||||||
<Avatar user={u} size="sm" />
|
|
||||||
<div className="flex-col flex-1 overflow-hidden">
|
|
||||||
<div className="flex items-center gap-2" style={{ gap: 8 }}>
|
|
||||||
<span className="font-medium text-sm">{u.display_name || u.name}</span>
|
|
||||||
<span className={`role-badge role-${u.role}`}>{u.role}</span>
|
|
||||||
{u.status !== 'active' && <span className="role-badge status-suspended">{u.status}</span>}
|
|
||||||
{u.is_default_admin ? <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Default Admin</span> : null}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{u.email}</span>
|
|
||||||
{u.must_change_password ? <span className="text-xs" style={{ color: 'var(--warning)' }}>⚠ Must change password</span> : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{!u.is_default_admin && (
|
|
||||||
<div className="flex gap-1" style={{ gap: 4 }}>
|
|
||||||
{resetingId === u.id ? (
|
|
||||||
<div className="flex gap-1" style={{ gap: 4 }}>
|
|
||||||
<input className="input" style={{ width: 130, fontSize: 12, padding: '4px 8px' }} type="password" placeholder="New password" value={resetPw} onChange={e => setResetPw(e.target.value)} />
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => handleResetPw(u.id)}>Set</button>
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => { setResetingId(null); setResetPw(''); }}>✕</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => setResetingId(u.id)} title="Reset password">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
||||||
Reset PW
|
|
||||||
</button>
|
|
||||||
<select
|
|
||||||
value={u.role}
|
|
||||||
onChange={e => handleRole(u, e.target.value)}
|
|
||||||
className="input"
|
|
||||||
style={{ width: 90, padding: '4px 6px', fontSize: 12 }}
|
|
||||||
>
|
|
||||||
<option value="member">Member</option>
|
|
||||||
<option value="admin">Admin</option>
|
|
||||||
</select>
|
|
||||||
{u.status === 'active' ? (
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => handleSuspend(u)}>Suspend</button>
|
|
||||||
) : u.status === 'suspended' ? (
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => handleActivate(u)} style={{ color: 'var(--success)' }}>Activate</button>
|
|
||||||
) : null}
|
|
||||||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(u)}>Delete</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -209,20 +303,20 @@ export default function UserManagerModal({ onClose }) {
|
|||||||
{/* Create user */}
|
{/* Create user */}
|
||||||
{tab === 'create' && (
|
{tab === 'create' && (
|
||||||
<div className="flex-col gap-3">
|
<div className="flex-col gap-3">
|
||||||
<div className="flex gap-3" style={{ gap: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
<div className="flex-col gap-1 flex-1">
|
<div className="flex-col gap-1">
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Full Name</label>
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Full Name <span style={{ fontWeight: 400, color: 'var(--text-tertiary)' }}>(First Last)</span></label>
|
||||||
<input className="input" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} />
|
<input className="input" placeholder="Jane Smith" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-col gap-1 flex-1">
|
<div className="flex-col gap-1">
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Email</label>
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Email</label>
|
||||||
<input className="input" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
<input className="input" type="email" placeholder="jane@example.com" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3" style={{ gap: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12 }}>
|
||||||
<div className="flex-col gap-1 flex-1">
|
<div className="flex-col gap-1">
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Temp Password</label>
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Temp Password <span style={{ fontWeight: 400, color: 'var(--text-tertiary)' }}>(blank = default)</span></label>
|
||||||
<input className="input" type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
<input className="input" type="text" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-col gap-1">
|
<div className="flex-col gap-1">
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Role</label>
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Role</label>
|
||||||
@@ -232,8 +326,8 @@ export default function UserManagerModal({ onClose }) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>User will be required to change their password on first login.</p>
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>User must change password on first login. Duplicate names get a number suffix automatically.</p>
|
||||||
<button className="btn btn-primary" onClick={handleCreate} disabled={creating}>{creating ? 'Creating...' : 'Create User'}</button>
|
<button className="btn btn-primary" onClick={handleCreate} disabled={creating}>{creating ? 'Creating…' : 'Create User'}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -241,44 +335,65 @@ export default function UserManagerModal({ onClose }) {
|
|||||||
{tab === 'bulk' && (
|
{tab === 'bulk' && (
|
||||||
<div className="flex-col gap-4">
|
<div className="flex-col gap-4">
|
||||||
<div className="card" style={{ background: 'var(--background)', border: '1px dashed var(--border)' }}>
|
<div className="card" style={{ background: 'var(--background)', border: '1px dashed var(--border)' }}>
|
||||||
<p className="text-sm font-medium" style={{ marginBottom: 8 }}>CSV Format</p>
|
<p className="text-sm font-medium" style={{ marginBottom: 6 }}>CSV Format</p>
|
||||||
<code style={{ fontSize: 12, color: 'var(--text-secondary)', display: 'block', background: 'white', padding: 8, borderRadius: 4, border: '1px solid var(--border)' }}>
|
<code style={{ fontSize: 12, color: 'var(--text-secondary)', display: 'block', background: 'var(--surface)', padding: 8, borderRadius: 4, border: '1px solid var(--border)', whiteSpace: 'pre' }}>name,email,password,role{'\n'}Jane Smith,jane@company.local,,member{'\n'}Bob Jones,bob@company.com,TempPass1,admin</code>
|
||||||
name,email,password,role{'\n'}
|
|
||||||
John Doe,john@example.com,TempPass123,member
|
|
||||||
</code>
|
|
||||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)', marginTop: 8 }}>
|
<p className="text-xs" style={{ color: 'var(--text-tertiary)', marginTop: 8 }}>
|
||||||
role can be "member" or "admin". Password defaults to TempPass@123 if omitted. All users must change password on first login.
|
Name and email are required. If left blank, Temp Password defaults to <strong>{userPass}</strong>, Role defaults to member. Lines with duplicate emails are skipped. Duplicate names get a number suffix.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="btn btn-secondary pointer" style={{ alignSelf: 'flex-start' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||||
Select CSV File
|
<label className="btn btn-secondary" style={{ cursor: 'pointer', margin: 0, flexShrink: 0 }}>
|
||||||
<input ref={fileRef} type="file" accept=".csv" style={{ display: 'none' }} onChange={handleCSV} />
|
Select CSV File
|
||||||
</label>
|
<input ref={fileRef} type="file" accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileSelect} />
|
||||||
|
</label>
|
||||||
{bulkPreview.length > 0 && (
|
{csvFile && (
|
||||||
<>
|
<span className="text-sm" style={{ color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
||||||
<div>
|
{csvFile.name}
|
||||||
<p className="text-sm font-medium" style={{ marginBottom: 8 }}>Preview ({bulkPreview.length} users)</p>
|
{csvRows.length > 0 && <span style={{ color: 'var(--text-tertiary)', marginLeft: 6 }}>({csvRows.length} valid)</span>}
|
||||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', maxHeight: 200, overflowY: 'auto' }}>
|
</span>
|
||||||
{bulkPreview.slice(0, 10).map((u, i) => (
|
)}
|
||||||
<div key={i} className="flex items-center gap-2" style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 13, gap: 12 }}>
|
{csvRows.length > 0 && (
|
||||||
<span className="flex-1">{u.name}</span>
|
<button className="btn btn-primary" style={{ flexShrink: 0 }} onClick={handleBulkImport} disabled={bulkLoading}>
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{u.email}</span>
|
{bulkLoading ? 'Creating…' : `Create ${csvRows.length} User${csvRows.length !== 1 ? 's' : ''}`}
|
||||||
<span className={`role-badge role-${u.role}`}>{u.role}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{bulkPreview.length > 10 && (
|
|
||||||
<div style={{ padding: '8px 12px', color: 'var(--text-tertiary)', fontSize: 13 }}>
|
|
||||||
...and {bulkPreview.length - 10} more
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="btn btn-primary" onClick={handleBulkImport} disabled={bulkLoading}>
|
|
||||||
{bulkLoading ? 'Importing...' : `Import ${bulkPreview.length} Users`}
|
|
||||||
</button>
|
</button>
|
||||||
</>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{csvInvalid.length > 0 && (
|
||||||
|
<div style={{ background: 'rgba(229,57,53,0.07)', border: '1px solid #e53935', borderRadius: 'var(--radius)', padding: 10 }}>
|
||||||
|
<p className="text-sm font-medium" style={{ color: '#e53935', marginBottom: 6 }}>{csvInvalid.length} line{csvInvalid.length !== 1 ? 's' : ''} skipped — invalid format</p>
|
||||||
|
<div style={{ maxHeight: 100, overflowY: 'auto' }}>
|
||||||
|
{csvInvalid.map((e, i) => (
|
||||||
|
<div key={i} style={{ fontSize: 12, padding: '2px 0', color: 'var(--text-secondary)' }}>
|
||||||
|
<code style={{ fontSize: 11 }}>{e.line}</code>
|
||||||
|
<span style={{ color: '#e53935', marginLeft: 8 }}>— {e.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bulkResult && (
|
||||||
|
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: 12 }}>
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--success, #2e7d32)', marginBottom: bulkResult.skipped.length ? 8 : 0 }}>
|
||||||
|
✓ {bulkResult.created.length} user{bulkResult.created.length !== 1 ? 's' : ''} created successfully
|
||||||
|
</p>
|
||||||
|
{bulkResult.skipped.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 6 }}>{bulkResult.skipped.length} account{bulkResult.skipped.length !== 1 ? 's' : ''} skipped:</p>
|
||||||
|
<div style={{ maxHeight: 112, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
||||||
|
{bulkResult.skipped.map((s, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '5px 10px', borderBottom: i < bulkResult.skipped.length - 1 ? '1px solid var(--border)' : 'none', fontSize: 13, gap: 12 }}>
|
||||||
|
<span style={{ color: 'var(--text-primary)' }}>{s.email}</span>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}>{s.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-secondary btn-sm" style={{ marginTop: 10 }} onClick={() => setBulkResult(null)}>Dismiss</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import Avatar from './Avatar.jsx';
|
import Avatar from './Avatar.jsx';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
|
|
||||||
export default function UserProfilePopup({ user, anchorEl, onClose }) {
|
export default function UserProfilePopup({ user: profileUser, anchorEl, onClose, onDirectMessage }) {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
const popupRef = useRef(null);
|
const popupRef = useRef(null);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
|
||||||
|
const isSelf = currentUser?.id === profileUser?.id;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
@@ -15,7 +21,6 @@ export default function UserProfilePopup({ user, anchorEl, onClose }) {
|
|||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, [anchorEl, onClose]);
|
}, [anchorEl, onClose]);
|
||||||
|
|
||||||
// Position near the anchor element
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!popupRef.current || !anchorEl) return;
|
if (!popupRef.current || !anchorEl) return;
|
||||||
const anchor = anchorEl.getBoundingClientRect();
|
const anchor = anchorEl.getBoundingClientRect();
|
||||||
@@ -23,20 +28,35 @@ export default function UserProfilePopup({ user, anchorEl, onClose }) {
|
|||||||
const viewportH = window.innerHeight;
|
const viewportH = window.innerHeight;
|
||||||
const viewportW = window.innerWidth;
|
const viewportW = window.innerWidth;
|
||||||
|
|
||||||
// Default: below and to the right of avatar
|
|
||||||
let top = anchor.bottom + 8;
|
let top = anchor.bottom + 8;
|
||||||
let left = anchor.left;
|
let left = anchor.left;
|
||||||
|
|
||||||
// Flip up if not enough space below
|
if (top + 260 > viewportH) top = anchor.top - 268;
|
||||||
if (top + 220 > viewportH) top = anchor.top - 228;
|
|
||||||
// Clamp right edge
|
|
||||||
if (left + 220 > viewportW) left = viewportW - 228;
|
if (left + 220 > viewportW) left = viewportW - 228;
|
||||||
|
|
||||||
popup.style.top = `${top}px`;
|
popup.style.top = `${top}px`;
|
||||||
popup.style.left = `${left}px`;
|
popup.style.left = `${left}px`;
|
||||||
}, [anchorEl]);
|
}, [anchorEl]);
|
||||||
|
|
||||||
if (!user) return null;
|
const handleDM = async () => {
|
||||||
|
if (!onDirectMessage) return;
|
||||||
|
setStarting(true);
|
||||||
|
try {
|
||||||
|
const { group } = await api.createGroup({
|
||||||
|
type: 'private',
|
||||||
|
memberIds: [profileUser.id],
|
||||||
|
isDirect: true,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
onDirectMessage(group);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('DM error', e);
|
||||||
|
} finally {
|
||||||
|
setStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!profileUser) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -44,7 +64,7 @@ export default function UserProfilePopup({ user, anchorEl, onClose }) {
|
|||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
background: 'white',
|
background: 'var(--surface)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
|
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
|
||||||
@@ -56,26 +76,56 @@ export default function UserProfilePopup({ user, anchorEl, onClose }) {
|
|||||||
gap: 8,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Avatar user={user} size="xl" />
|
<Avatar user={profileUser} size="xl" />
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)', marginBottom: 2 }}>
|
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)', marginBottom: 2 }}>
|
||||||
{user.display_name || user.name}
|
{profileUser.name}
|
||||||
</div>
|
</div>
|
||||||
{user.role === 'admin' && !user.hide_admin_tag && (
|
{profileUser.role === 'admin' && !profileUser.hide_admin_tag && (
|
||||||
<span className="role-badge role-admin" style={{ fontSize: 11 }}>Admin</span>
|
<span className="role-badge role-admin" style={{ fontSize: 11 }}>Admin</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{user.about_me && (
|
{profileUser.about_me && (
|
||||||
<p style={{
|
<p style={{
|
||||||
fontSize: 13, color: 'var(--text-secondary)',
|
fontSize: 13, color: 'var(--text-secondary)',
|
||||||
textAlign: 'center', lineHeight: 1.5,
|
textAlign: 'center', lineHeight: 1.5,
|
||||||
marginTop: 4, wordBreak: 'break-word',
|
marginTop: 4, wordBreak: 'break-word',
|
||||||
borderTop: '1px solid var(--border)',
|
borderTop: '1px solid var(--border)',
|
||||||
paddingTop: 10, width: '100%'
|
paddingTop: 10, width: '100%',
|
||||||
}}>
|
}}>
|
||||||
{user.about_me}
|
{profileUser.about_me}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{!isSelf && onDirectMessage && (
|
||||||
|
<button
|
||||||
|
onClick={handleDM}
|
||||||
|
disabled={starting}
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 0',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
border: '1px solid var(--primary)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--primary)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: starting ? 'default' : 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 6,
|
||||||
|
transition: 'background var(--transition), color var(--transition)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--primary)'; e.currentTarget.style.color = 'white'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--primary)'; }}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
{starting ? 'Opening...' : 'Direct Message'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
--font: 'Google Sans', 'Roboto', sans-serif;
|
--font: 'Google Sans', 'Roboto', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body, #root { height: 100%; font-family: var(--font); color: var(--text-primary); background: var(--background); }
|
html, body, #root { height: 100%; min-width: 320px; font-family: var(--font); color: var(--text-primary); background: var(--background); }
|
||||||
|
|
||||||
button { font-family: var(--font); cursor: pointer; border: none; background: none; }
|
button { font-family: var(--font); cursor: pointer; border: none; background: none; }
|
||||||
input, textarea { font-family: var(--font); }
|
input, textarea { font-family: var(--font); }
|
||||||
@@ -226,6 +226,9 @@ a { color: inherit; text-decoration: none; }
|
|||||||
[data-theme="dark"] .card { background: var(--surface); border-color: var(--border); }
|
[data-theme="dark"] .card { background: var(--surface); border-color: var(--border); }
|
||||||
[data-theme="dark"] .message-input-area { background: var(--surface); border-color: var(--border); }
|
[data-theme="dark"] .message-input-area { background: var(--surface); border-color: var(--border); }
|
||||||
[data-theme="dark"] .message-input-wrap { background: var(--surface-variant); border-color: var(--border); }
|
[data-theme="dark"] .message-input-wrap { background: var(--surface-variant); border-color: var(--border); }
|
||||||
|
[data-theme="dark"] .msg-input:focus { background: var(--surface-variant); color: var(--text-primary); }
|
||||||
|
/* Light mode: focused input goes white so it pops from the grey background */
|
||||||
|
[data-theme="light"] .msg-input:focus, :root:not([data-theme="dark"]) .msg-input:focus { background: white; }
|
||||||
[data-theme="dark"] .btn-secondary { border-color: var(--border); color: var(--primary); }
|
[data-theme="dark"] .btn-secondary { border-color: var(--border); color: var(--primary); }
|
||||||
[data-theme="dark"] .btn-secondary:hover { background: var(--primary-light); }
|
[data-theme="dark"] .btn-secondary:hover { background: var(--primary-light); }
|
||||||
[data-theme="dark"] .search-input { background: var(--surface-variant); color: var(--text-primary); }
|
[data-theme="dark"] .search-input { background: var(--surface-variant); color: var(--text-primary); }
|
||||||
@@ -236,6 +239,10 @@ a { color: inherit; text-decoration: none; }
|
|||||||
[data-theme="dark"] .footer-menu-item.danger:hover { background: #3a1a1a; }
|
[data-theme="dark"] .footer-menu-item.danger:hover { background: #3a1a1a; }
|
||||||
[data-theme="dark"] .btn-icon { color: var(--text-primary); }
|
[data-theme="dark"] .btn-icon { color: var(--text-primary); }
|
||||||
[data-theme="dark"] .btn-icon:hover { background: var(--surface-variant); }
|
[data-theme="dark"] .btn-icon:hover { background: var(--surface-variant); }
|
||||||
|
[data-theme="dark"] .sidebar-header { background: var(--surface); border-color: var(--border); }
|
||||||
|
[data-theme="dark"] .newchat-btn { background: var(--surface-variant); border-color: var(--primary); color: var(--primary); }
|
||||||
|
[data-theme="dark"] .newchat-btn:hover { background: var(--primary); color: white; }
|
||||||
|
[data-theme="dark"] .newchat-fab { background: var(--primary); }
|
||||||
[data-theme="dark"] .msg-actions { background: var(--surface); border-color: var(--border); }
|
[data-theme="dark"] .msg-actions { background: var(--surface); border-color: var(--border); }
|
||||||
[data-theme="dark"] .reaction-btn:hover { background: var(--surface-variant); }
|
[data-theme="dark"] .reaction-btn:hover { background: var(--surface-variant); }
|
||||||
[data-theme="dark"] .emoji-picker-wrap { background: var(--surface); border-color: var(--border); }
|
[data-theme="dark"] .emoji-picker-wrap { background: var(--surface); border-color: var(--border); }
|
||||||
@@ -243,3 +250,92 @@ a { color: inherit; text-decoration: none; }
|
|||||||
[data-theme="dark"] .load-more-btn { background: var(--surface-variant); color: var(--text-secondary); }
|
[data-theme="dark"] .load-more-btn { background: var(--surface-variant); color: var(--text-secondary); }
|
||||||
[data-theme="dark"] .readonly-bar { background: var(--surface); border-color: var(--border); color: var(--text-secondary); }
|
[data-theme="dark"] .readonly-bar { background: var(--surface); border-color: var(--border); color: var(--text-secondary); }
|
||||||
[data-theme="dark"] .warning-banner { background: #2a1f00; border-color: #6a4a00; color: #ffb74d; }
|
[data-theme="dark"] .warning-banner { background: #2a1f00; border-color: #6a4a00; color: #ffb74d; }
|
||||||
|
|
||||||
|
/* ── About Modal ─────────────────────────────────────── */
|
||||||
|
.about-modal {
|
||||||
|
max-width: 420px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
padding: 32px 28px 24px;
|
||||||
|
}
|
||||||
|
.about-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
.about-hero {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.about-logo {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.about-appname {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--primary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
.about-tagline {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.about-table {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: visible;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.about-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.about-row:last-child { border-bottom: none; }
|
||||||
|
.about-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
min-width: 90px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
.about-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: normal;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.about-mono {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.about-link {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.about-link:hover { opacity: 0.8; }
|
||||||
|
.about-footer {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .about-table { border-color: var(--border); }
|
||||||
|
[data-theme="dark"] .about-row { border-color: var(--border); }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ if ('serviceWorker' in navigator) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Clear badge count when user focuses the app
|
// Clear badge count when user focuses the app
|
||||||
window.addEventListener('focus', () => {
|
window.addEventListener('focus', () => {
|
||||||
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
|
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
|
||||||
|
|||||||
@@ -1,12 +1,94 @@
|
|||||||
.chat-layout {
|
.chat-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global top bar */
|
||||||
|
.global-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 16px;
|
||||||
|
height: 72px;
|
||||||
|
min-height: 72px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
z-index: 20;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-bar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-bar-logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-bar-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-bar-offline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #e53935;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body below global bar */
|
||||||
|
.chat-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0; /* allows body to shrink when mobile keyboard resizes viewport */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.chat-layout {
|
.chat-layout {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
.chat-body {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.global-bar {
|
||||||
|
height: 56px;
|
||||||
|
min-height: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .global-bar {
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-chat-selected {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
gap: 4px;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import ProfileModal from '../components/ProfileModal.jsx';
|
|||||||
import UserManagerModal from '../components/UserManagerModal.jsx';
|
import UserManagerModal from '../components/UserManagerModal.jsx';
|
||||||
import SettingsModal from '../components/SettingsModal.jsx';
|
import SettingsModal from '../components/SettingsModal.jsx';
|
||||||
import NewChatModal from '../components/NewChatModal.jsx';
|
import NewChatModal from '../components/NewChatModal.jsx';
|
||||||
|
import GlobalBar from '../components/GlobalBar.jsx';
|
||||||
|
import AboutModal from '../components/AboutModal.jsx';
|
||||||
import './Chat.css';
|
import './Chat.css';
|
||||||
|
|
||||||
function urlBase64ToUint8Array(base64String) {
|
function urlBase64ToUint8Array(base64String) {
|
||||||
@@ -99,7 +101,8 @@ export default function Chat() {
|
|||||||
privateGroups: prev.privateGroups.map(updateGroup),
|
privateGroups: prev.privateGroups.map(updateGroup),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
// Increment unread count for the group if not currently viewing it
|
// Don't badge: message is from this user, or group is currently open
|
||||||
|
if (msg.user_id === user?.id) return;
|
||||||
setUnreadGroups(prev => {
|
setUnreadGroups(prev => {
|
||||||
if (msg.group_id === activeGroupId) return prev;
|
if (msg.group_id === activeGroupId) return prev;
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -110,14 +113,8 @@ export default function Chat() {
|
|||||||
|
|
||||||
const handleNotification = (notif) => {
|
const handleNotification = (notif) => {
|
||||||
if (notif.type === 'private_message') {
|
if (notif.type === 'private_message') {
|
||||||
// Private message unread is already handled by handleNewMsg above
|
// Badge is already handled by handleNewMsg via message:new socket event.
|
||||||
// (kept for push notification path when socket is not the source)
|
// Nothing to do here for the socket path.
|
||||||
setUnreadGroups(prev => {
|
|
||||||
if (notif.groupId === activeGroupId) return prev;
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(notif.groupId, (next.get(notif.groupId) || 0) + 1);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
setNotifications(prev => [notif, ...prev]);
|
setNotifications(prev => [notif, ...prev]);
|
||||||
toast(`${notif.fromUser?.display_name || notif.fromUser?.name || 'Someone'} mentioned you`, 'default', 4000);
|
toast(`${notif.fromUser?.display_name || notif.fromUser?.name || 'Someone'} mentioned you`, 'default', 4000);
|
||||||
@@ -127,11 +124,51 @@ export default function Chat() {
|
|||||||
socket.on('message:new', handleNewMsg);
|
socket.on('message:new', handleNewMsg);
|
||||||
socket.on('notification:new', handleNotification);
|
socket.on('notification:new', handleNotification);
|
||||||
|
|
||||||
|
// Group list real-time updates
|
||||||
|
const handleGroupNew = ({ group }) => {
|
||||||
|
// Join the socket room for this new group
|
||||||
|
socket.emit('group:join-room', { groupId: group.id });
|
||||||
|
// Reload the full group list so name/metadata is correct
|
||||||
|
loadGroups();
|
||||||
|
};
|
||||||
|
const handleGroupDeleted = ({ groupId }) => {
|
||||||
|
// Leave the socket room so we stop receiving events for this group
|
||||||
|
socket.emit('group:leave-room', { groupId });
|
||||||
|
setGroups(prev => ({
|
||||||
|
publicGroups: prev.publicGroups.filter(g => g.id !== groupId),
|
||||||
|
privateGroups: prev.privateGroups.filter(g => g.id !== groupId),
|
||||||
|
}));
|
||||||
|
setActiveGroupId(prev => {
|
||||||
|
if (prev === groupId) {
|
||||||
|
if (isMobile) setShowSidebar(true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
setUnreadGroups(prev => { const next = new Map(prev); next.delete(groupId); return next; });
|
||||||
|
};
|
||||||
|
const handleGroupUpdated = ({ group }) => {
|
||||||
|
setGroups(prev => {
|
||||||
|
const update = g => g.id === group.id ? { ...g, ...group } : g;
|
||||||
|
return {
|
||||||
|
publicGroups: prev.publicGroups.map(update),
|
||||||
|
privateGroups: prev.privateGroups.map(update),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('group:new', handleGroupNew);
|
||||||
|
socket.on('group:deleted', handleGroupDeleted);
|
||||||
|
socket.on('group:updated', handleGroupUpdated);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off('message:new', handleNewMsg);
|
socket.off('message:new', handleNewMsg);
|
||||||
socket.off('notification:new', handleNotification);
|
socket.off('notification:new', handleNotification);
|
||||||
|
socket.off('group:new', handleGroupNew);
|
||||||
|
socket.off('group:deleted', handleGroupDeleted);
|
||||||
|
socket.off('group:updated', handleGroupUpdated);
|
||||||
};
|
};
|
||||||
}, [socket, toast]);
|
}, [socket, toast, activeGroupId, user, isMobile, loadGroups]);
|
||||||
|
|
||||||
const selectGroup = (id) => {
|
const selectGroup = (id) => {
|
||||||
setActiveGroupId(id);
|
setActiveGroupId(id);
|
||||||
@@ -141,6 +178,13 @@ export default function Chat() {
|
|||||||
setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; });
|
setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Update page title with total unread badge count
|
||||||
|
useEffect(() => {
|
||||||
|
const totalUnread = [...unreadGroups.values()].reduce((a, b) => a + b, 0);
|
||||||
|
const base = document.title.replace(/^\(\d+\)\s*/, '');
|
||||||
|
document.title = totalUnread > 0 ? `(${totalUnread}) ${base}` : base;
|
||||||
|
}, [unreadGroups]);
|
||||||
|
|
||||||
const activeGroup = [
|
const activeGroup = [
|
||||||
...(groups.publicGroups || []),
|
...(groups.publicGroups || []),
|
||||||
...(groups.privateGroups || [])
|
...(groups.privateGroups || [])
|
||||||
@@ -148,33 +192,42 @@ export default function Chat() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-layout">
|
<div className="chat-layout">
|
||||||
{(!isMobile || showSidebar) && (
|
{/* Global top bar — spans full width on desktop, visible on mobile sidebar view */}
|
||||||
<Sidebar
|
<GlobalBar isMobile={isMobile} showSidebar={showSidebar} />
|
||||||
groups={groups}
|
|
||||||
activeGroupId={activeGroupId}
|
|
||||||
onSelectGroup={selectGroup}
|
|
||||||
notifications={notifications}
|
|
||||||
unreadGroups={unreadGroups}
|
|
||||||
onNewChat={() => setModal('newchat')}
|
|
||||||
onProfile={() => setModal('profile')}
|
|
||||||
onUsers={() => setModal('users')}
|
|
||||||
onSettings={() => setModal('settings')}
|
|
||||||
onGroupsUpdated={loadGroups}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(!isMobile || !showSidebar) && (
|
<div className="chat-body">
|
||||||
<ChatWindow
|
{(!isMobile || showSidebar) && (
|
||||||
group={activeGroup}
|
<Sidebar
|
||||||
onBack={isMobile ? () => setShowSidebar(true) : null}
|
groups={groups}
|
||||||
onGroupUpdated={loadGroups}
|
activeGroupId={activeGroupId}
|
||||||
/>
|
onSelectGroup={selectGroup}
|
||||||
)}
|
notifications={notifications}
|
||||||
|
unreadGroups={unreadGroups}
|
||||||
|
onNewChat={() => setModal('newchat')}
|
||||||
|
onProfile={() => setModal('profile')}
|
||||||
|
onUsers={() => setModal('users')}
|
||||||
|
onSettings={() => setModal('settings')}
|
||||||
|
onGroupsUpdated={loadGroups}
|
||||||
|
isMobile={isMobile}
|
||||||
|
onAbout={() => setModal('about')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!isMobile || !showSidebar) && (
|
||||||
|
<ChatWindow
|
||||||
|
group={activeGroup}
|
||||||
|
onBack={isMobile ? () => { setShowSidebar(true); setActiveGroupId(null); } : null}
|
||||||
|
onGroupUpdated={loadGroups}
|
||||||
|
onDirectMessage={(g) => { loadGroups(); selectGroup(g.id); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
|
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />}
|
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
||||||
|
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ function getToken() {
|
|||||||
return localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
return localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SQLite datetime('now') returns "YYYY-MM-DD HH:MM:SS" with no timezone marker.
|
||||||
|
// Browsers parse bare strings like this as LOCAL time, but the value is actually UTC.
|
||||||
|
// Appending 'Z' forces correct UTC interpretation so local display is always right.
|
||||||
|
export function parseTS(ts) {
|
||||||
|
if (!ts) return new Date(NaN);
|
||||||
|
// Already has timezone info (contains T and Z/+ or ends in Z) — leave alone
|
||||||
|
if (/Z$|[+-]\d{2}:\d{2}$/.test(ts) || (ts.includes('T') && ts.includes('Z'))) return new Date(ts);
|
||||||
|
// Replace the space separator SQLite uses and append Z
|
||||||
|
return new Date(ts.replace(' ', 'T') + 'Z');
|
||||||
|
}
|
||||||
|
|
||||||
async function req(method, path, body, opts = {}) {
|
async function req(method, path, body, opts = {}) {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const headers = {};
|
const headers = {};
|
||||||
@@ -45,11 +56,13 @@ export const api = {
|
|||||||
searchUsers: (q) => req('GET', `/users/search?q=${encodeURIComponent(q)}`),
|
searchUsers: (q) => req('GET', `/users/search?q=${encodeURIComponent(q)}`),
|
||||||
createUser: (body) => req('POST', '/users', body),
|
createUser: (body) => req('POST', '/users', body),
|
||||||
bulkUsers: (users) => req('POST', '/users/bulk', { users }),
|
bulkUsers: (users) => req('POST', '/users/bulk', { users }),
|
||||||
|
updateName: (id, name) => req('PATCH', `/users/${id}/name`, { name }),
|
||||||
updateRole: (id, role) => req('PATCH', `/users/${id}/role`, { role }),
|
updateRole: (id, role) => req('PATCH', `/users/${id}/role`, { role }),
|
||||||
resetPassword: (id, password) => req('PATCH', `/users/${id}/reset-password`, { password }),
|
resetPassword: (id, password) => req('PATCH', `/users/${id}/reset-password`, { password }),
|
||||||
suspendUser: (id) => req('PATCH', `/users/${id}/suspend`),
|
suspendUser: (id) => req('PATCH', `/users/${id}/suspend`),
|
||||||
activateUser: (id) => req('PATCH', `/users/${id}/activate`),
|
activateUser: (id) => req('PATCH', `/users/${id}/activate`),
|
||||||
deleteUser: (id) => req('DELETE', `/users/${id}`),
|
deleteUser: (id) => req('DELETE', `/users/${id}`),
|
||||||
|
checkDisplayName: (name) => req('GET', `/users/check-display-name?name=${encodeURIComponent(name)}`),
|
||||||
updateProfile: (body) => req('PATCH', '/users/me/profile', body), // body: { displayName, aboutMe, hideAdminTag }
|
updateProfile: (body) => req('PATCH', '/users/me/profile', body), // body: { displayName, aboutMe, hideAdminTag }
|
||||||
uploadAvatar: (file) => {
|
uploadAvatar: (file) => {
|
||||||
const form = new FormData(); form.append('avatar', file);
|
const form = new FormData(); form.append('avatar', file);
|
||||||
|
|||||||