Add sharing rooms
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m8s
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m8s
This commit is contained in:
@@ -157,9 +157,19 @@ export async function initDatabase() {
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_shares (
|
||||
id SERIAL PRIMARY KEY,
|
||||
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(room_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
@@ -197,9 +207,21 @@ export async function initDatabase() {
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(room_id, user_id),
|
||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id);
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,25 +22,56 @@ function getUserAvatarURL(req, user) {
|
||||
return `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(user.name)}${color}`;
|
||||
}
|
||||
|
||||
// GET /api/rooms - List user's rooms
|
||||
// GET /api/rooms - List user's rooms (owned + shared)
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rooms = await db.all(`
|
||||
SELECT r.*, u.name as owner_name
|
||||
const ownRooms = await db.all(`
|
||||
SELECT r.*, u.name as owner_name, 0 as shared
|
||||
FROM rooms r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
WHERE r.user_id = ?
|
||||
ORDER BY r.created_at DESC
|
||||
`, [req.user.id]);
|
||||
|
||||
res.json({ rooms });
|
||||
const sharedRooms = await db.all(`
|
||||
SELECT r.*, u.name as owner_name, 1 as shared
|
||||
FROM rooms r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
JOIN room_shares rs ON rs.room_id = r.id
|
||||
WHERE rs.user_id = ?
|
||||
ORDER BY r.created_at DESC
|
||||
`, [req.user.id]);
|
||||
|
||||
res.json({ rooms: [...ownRooms, ...sharedRooms] });
|
||||
} catch (err) {
|
||||
console.error('List rooms error:', err);
|
||||
res.status(500).json({ error: 'Räume konnten nicht geladen werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/rooms/users/search - Search users for sharing (must be before /:uid routes)
|
||||
router.get('/users/search', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
if (!q || q.length < 2) {
|
||||
return res.json({ users: [] });
|
||||
}
|
||||
const db = getDb();
|
||||
const searchTerm = `%${q}%`;
|
||||
const users = await db.all(`
|
||||
SELECT id, name, email, avatar_color, avatar_image
|
||||
FROM users
|
||||
WHERE (name LIKE ? OR email LIKE ?) AND id != ?
|
||||
LIMIT 10
|
||||
`, [searchTerm, searchTerm, req.user.id]);
|
||||
res.json({ users });
|
||||
} catch (err) {
|
||||
console.error('Search users error:', err);
|
||||
res.status(500).json({ error: 'Benutzersuche fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/rooms/:uid - Get room details
|
||||
router.get('/:uid', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
@@ -56,7 +87,24 @@ router.get('/:uid', authenticateToken, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json({ room });
|
||||
// Check access: owner, admin, or shared
|
||||
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||
if (!share) {
|
||||
return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
}
|
||||
room.shared = 1;
|
||||
}
|
||||
|
||||
// Get shared users
|
||||
const sharedUsers = await db.all(`
|
||||
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM room_shares rs
|
||||
JOIN users u ON rs.user_id = u.id
|
||||
WHERE rs.room_id = ?
|
||||
`, [room.id]);
|
||||
|
||||
res.json({ room, sharedUsers });
|
||||
} catch (err) {
|
||||
console.error('Get room error:', err);
|
||||
res.status(500).json({ error: 'Raum konnte nicht geladen werden' });
|
||||
@@ -197,15 +245,100 @@ router.delete('/:uid', authenticateToken, async (req, res) => {
|
||||
res.status(500).json({ error: 'Raum konnte nicht gelöscht werden' });
|
||||
}
|
||||
});
|
||||
// GET /api/rooms/:uid/shares - Get shared users for a room
|
||||
router.get('/:uid/shares', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
|
||||
}
|
||||
const shares = await db.all(`
|
||||
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM room_shares rs
|
||||
JOIN users u ON rs.user_id = u.id
|
||||
WHERE rs.room_id = ?
|
||||
`, [room.id]);
|
||||
res.json({ shares });
|
||||
} catch (err) {
|
||||
console.error('Get shares error:', err);
|
||||
res.status(500).json({ error: 'Fehler beim Laden der Freigaben' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rooms/:uid/shares - Share room with a user
|
||||
router.post('/:uid/shares', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { user_id } = req.body;
|
||||
if (!user_id) {
|
||||
return res.status(400).json({ error: 'Benutzer-ID erforderlich' });
|
||||
}
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
|
||||
}
|
||||
if (user_id === req.user.id) {
|
||||
return res.status(400).json({ error: 'Du kannst den Raum nicht mit dir selbst teilen' });
|
||||
}
|
||||
// Check if already shared
|
||||
const existing = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, user_id]);
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Raum ist bereits mit diesem Benutzer geteilt' });
|
||||
}
|
||||
await db.run('INSERT INTO room_shares (room_id, user_id) VALUES (?, ?)', [room.id, user_id]);
|
||||
const shares = await db.all(`
|
||||
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM room_shares rs
|
||||
JOIN users u ON rs.user_id = u.id
|
||||
WHERE rs.room_id = ?
|
||||
`, [room.id]);
|
||||
res.json({ shares });
|
||||
} catch (err) {
|
||||
console.error('Share room error:', err);
|
||||
res.status(500).json({ error: 'Fehler beim Teilen des Raums' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/rooms/:uid/shares/:userId - Remove share
|
||||
router.delete('/:uid/shares/:userId', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
|
||||
}
|
||||
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, parseInt(req.params.userId)]);
|
||||
const shares = await db.all(`
|
||||
SELECT u.id, u.name, u.email, u.avatar_color, u.avatar_image
|
||||
FROM room_shares rs
|
||||
JOIN users u ON rs.user_id = u.id
|
||||
WHERE rs.room_id = ?
|
||||
`, [room.id]);
|
||||
res.json({ shares });
|
||||
} catch (err) {
|
||||
console.error('Remove share error:', err);
|
||||
res.status(500).json({ error: 'Fehler beim Entfernen der Freigabe' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rooms/:uid/start - Start meeting
|
||||
router.post('/:uid/start', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
}
|
||||
|
||||
// Check access: owner or shared user
|
||||
const isOwner = room.user_id === req.user.id;
|
||||
if (!isOwner) {
|
||||
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||
if (!share) {
|
||||
return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
}
|
||||
}
|
||||
|
||||
await createMeeting(room, `${req.protocol}://${req.get('host')}`);
|
||||
@@ -239,7 +372,10 @@ router.post('/:uid/join', authenticateToken, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Meeting läuft nicht. Bitte warten Sie, bis der Moderator das Meeting gestartet hat.' });
|
||||
}
|
||||
|
||||
const isModerator = room.user_id === req.user.id || room.all_join_moderator;
|
||||
// Owner and shared users join as moderator
|
||||
const isOwner = room.user_id === req.user.id;
|
||||
const isShared = !isOwner && await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||
const isModerator = isOwner || !!isShared || room.all_join_moderator;
|
||||
const avatarURL = getUserAvatarURL(req, req.user);
|
||||
const joinUrl = await joinMeeting(room.uid, req.user.name, isModerator, avatarURL);
|
||||
res.json({ joinUrl });
|
||||
@@ -253,10 +389,19 @@ router.post('/:uid/join', authenticateToken, async (req, res) => {
|
||||
router.post('/:uid/end', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
}
|
||||
|
||||
// Check access: owner or shared user
|
||||
const isOwner = room.user_id === req.user.id;
|
||||
if (!isOwner) {
|
||||
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||
if (!share) {
|
||||
return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
}
|
||||
}
|
||||
|
||||
await endMeeting(room.uid);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Users, Play, Trash2, Radio, Loader2 } from 'lucide-react';
|
||||
import { Users, Play, Trash2, Radio, Loader2, Share2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
@@ -39,9 +39,15 @@ export default function RoomCard({ room, onDelete }) {
|
||||
{t('common.live')}
|
||||
</span>
|
||||
)}
|
||||
{room.shared ? (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
|
||||
<Share2 size={10} />
|
||||
{t('room.shared')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s mt-0.5">
|
||||
{room.uid.substring(0, 8)}...
|
||||
{room.shared ? room.owner_name : `${room.uid.substring(0, 8)}...`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,7 +99,7 @@ export default function RoomCard({ room, onDelete }) {
|
||||
{starting ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
|
||||
{status.running ? t('room.join') : t('room.startMeeting')}
|
||||
</button>
|
||||
{onDelete && (
|
||||
{onDelete && !room.shared && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(room); }}
|
||||
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
"roomDeleted": "Raum gelöscht",
|
||||
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
|
||||
"roomDeleteConfirm": "Raum \"{name}\" wirklich löschen?",
|
||||
"loadFailed": "Räume konnten nicht geladen werden"
|
||||
"loadFailed": "Räume konnten nicht geladen werden",
|
||||
"sharedWithMe": "Mit mir geteilt"
|
||||
},
|
||||
"room": {
|
||||
"backToDashboard": "Zurück zum Dashboard",
|
||||
@@ -186,7 +187,15 @@
|
||||
"guestWrongAccessCode": "Falscher Zugangscode",
|
||||
"guestHasAccount": "Haben Sie ein Konto?",
|
||||
"guestSignIn": "Anmelden",
|
||||
"guestRoomNotFound": "Raum nicht gefunden"
|
||||
"guestRoomNotFound": "Raum nicht gefunden",
|
||||
"shared": "Geteilt",
|
||||
"shareTitle": "Raum teilen",
|
||||
"shareDescription": "Teilen Sie diesen Raum mit anderen Benutzern, damit diese ihn in ihrem Dashboard sehen und beitreten k\u00f6nnen.",
|
||||
"shareSearchPlaceholder": "Benutzer suchen (Name oder E-Mail)...",
|
||||
"shareAdded": "Benutzer hinzugef\u00fcgt",
|
||||
"shareRemoved": "Freigabe entfernt",
|
||||
"shareFailed": "Freigabe fehlgeschlagen",
|
||||
"shareRemove": "Freigabe entfernen"
|
||||
},
|
||||
"recordings": {
|
||||
"title": "Aufnahmen",
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
"roomDeleted": "Room deleted",
|
||||
"roomDeleteFailed": "Room could not be deleted",
|
||||
"roomDeleteConfirm": "Really delete room \"{name}\"?",
|
||||
"loadFailed": "Rooms could not be loaded"
|
||||
"loadFailed": "Rooms could not be loaded",
|
||||
"sharedWithMe": "Shared with me"
|
||||
},
|
||||
"room": {
|
||||
"backToDashboard": "Back to Dashboard",
|
||||
@@ -186,7 +187,15 @@
|
||||
"guestWrongAccessCode": "Wrong access code",
|
||||
"guestHasAccount": "Have an account?",
|
||||
"guestSignIn": "Sign in",
|
||||
"guestRoomNotFound": "Room not found"
|
||||
"guestRoomNotFound": "Room not found",
|
||||
"shared": "Shared",
|
||||
"shareTitle": "Share room",
|
||||
"shareDescription": "Share this room with other users so they can see it in their dashboard and join meetings.",
|
||||
"shareSearchPlaceholder": "Search users (name or email)...",
|
||||
"shareAdded": "User added",
|
||||
"shareRemoved": "Share removed",
|
||||
"shareFailed": "Share failed",
|
||||
"shareRemove": "Remove share"
|
||||
},
|
||||
"recordings": {
|
||||
"title": "Recordings",
|
||||
|
||||
@@ -131,15 +131,36 @@ export default function Dashboard() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
: 'space-y-3'
|
||||
}>
|
||||
{rooms.map(room => (
|
||||
<RoomCard key={room.id} room={room} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{/* Own rooms */}
|
||||
{rooms.filter(r => !r.shared).length > 0 && (
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
: 'space-y-3'
|
||||
}>
|
||||
{rooms.filter(r => !r.shared).map(room => (
|
||||
<RoomCard key={room.id} room={room} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shared rooms */}
|
||||
{rooms.filter(r => r.shared).length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-semibold text-th-text mb-4">{t('dashboard.sharedWithMe')}</h2>
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
: 'space-y-3'
|
||||
}>
|
||||
{rooms.filter(r => r.shared).map(room => (
|
||||
<RoomCard key={`shared-${room.id}`} room={room} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create Room Modal */}
|
||||
|
||||
@@ -4,15 +4,18 @@ import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio } from 'lu
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
export default function GuestJoin() {
|
||||
const { uid } = useParams();
|
||||
const { t } = useLanguage();
|
||||
const { user } = useAuth();
|
||||
const isLoggedIn = !!user;
|
||||
const [roomInfo, setRoomInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [name, setName] = useState(user?.name || '');
|
||||
const [accessCode, setAccessCode] = useState('');
|
||||
const [moderatorCode, setModeratorCode] = useState('');
|
||||
const [status, setStatus] = useState({ running: false });
|
||||
@@ -162,11 +165,12 @@ export default function GuestJoin() {
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
onChange={e => !isLoggedIn && setName(e.target.value)}
|
||||
readOnly={isLoggedIn}
|
||||
className={`input-field pl-11 ${isLoggedIn ? 'opacity-70 cursor-not-allowed' : ''}`}
|
||||
placeholder={t('room.guestNamePlaceholder')}
|
||||
required
|
||||
autoFocus
|
||||
autoFocus={!isLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,11 +230,13 @@ export default function GuestJoin() {
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-th-border text-center">
|
||||
<Link to="/login" className="text-sm text-th-text-s hover:text-th-accent transition-colors">
|
||||
{t('room.guestHasAccount')} <span className="text-th-accent font-medium">{t('room.guestSignIn')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
{!isLoggedIn && (
|
||||
<div className="mt-6 pt-4 border-t border-th-border text-center">
|
||||
<Link to="/login" className="text-sm text-th-text-s hover:text-th-accent transition-colors">
|
||||
{t('room.guestHasAccount')} <span className="text-th-accent font-medium">{t('room.guestSignIn')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
|
||||
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
|
||||
Shield, Save,
|
||||
Shield, Save, UserPlus, X, Share2,
|
||||
} from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
@@ -25,14 +25,21 @@ export default function RoomDetail() {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [editRoom, setEditRoom] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [sharedUsers, setSharedUsers] = useState([]);
|
||||
const [shareSearch, setShareSearch] = useState('');
|
||||
const [shareResults, setShareResults] = useState([]);
|
||||
const [shareSearching, setShareSearching] = useState(false);
|
||||
|
||||
const isOwner = room && user && room.user_id === user.id;
|
||||
const isShared = room && !!room.shared;
|
||||
const canManage = isOwner || isShared;
|
||||
|
||||
const fetchRoom = async () => {
|
||||
try {
|
||||
const res = await api.get(`/rooms/${uid}`);
|
||||
setRoom(res.data.room);
|
||||
setEditRoom(res.data.room);
|
||||
if (res.data.sharedUsers) setSharedUsers(res.data.sharedUsers);
|
||||
} catch {
|
||||
toast.error(t('room.notFound'));
|
||||
navigate('/dashboard');
|
||||
@@ -144,6 +151,48 @@ export default function RoomDetail() {
|
||||
toast.success(t('room.linkCopied'));
|
||||
};
|
||||
|
||||
// Share functions
|
||||
const searchUsers = async (query) => {
|
||||
setShareSearch(query);
|
||||
if (query.length < 2) {
|
||||
setShareResults([]);
|
||||
return;
|
||||
}
|
||||
setShareSearching(true);
|
||||
try {
|
||||
const res = await api.get(`/rooms/users/search?q=${encodeURIComponent(query)}`);
|
||||
// Filter out already shared users
|
||||
const sharedIds = new Set(sharedUsers.map(u => u.id));
|
||||
setShareResults(res.data.users.filter(u => !sharedIds.has(u.id)));
|
||||
} catch {
|
||||
setShareResults([]);
|
||||
} finally {
|
||||
setShareSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async (userId) => {
|
||||
try {
|
||||
const res = await api.post(`/rooms/${uid}/shares`, { user_id: userId });
|
||||
setSharedUsers(res.data.shares);
|
||||
setShareSearch('');
|
||||
setShareResults([]);
|
||||
toast.success(t('room.shareAdded'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('room.shareFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnshare = async (userId) => {
|
||||
try {
|
||||
const res = await api.delete(`/rooms/${uid}/shares/${userId}`);
|
||||
setSharedUsers(res.data.shares);
|
||||
toast.success(t('room.shareRemoved'));
|
||||
} catch {
|
||||
toast.error(t('room.shareFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -203,7 +252,7 @@ export default function RoomDetail() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isOwner && !status.running && (
|
||||
{canManage && !status.running && (
|
||||
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
|
||||
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
|
||||
{t('room.start')}
|
||||
@@ -215,7 +264,7 @@ export default function RoomDetail() {
|
||||
{t('room.join')}
|
||||
</button>
|
||||
)}
|
||||
{isOwner && status.running && (
|
||||
{canManage && status.running && (
|
||||
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
|
||||
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
|
||||
{t('room.end')}
|
||||
@@ -448,6 +497,90 @@ export default function RoomDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Share section */}
|
||||
<div className="pt-4 border-t border-th-border space-y-4">
|
||||
<h3 className="text-sm font-semibold text-th-text flex items-center gap-2">
|
||||
<Share2 size={16} />
|
||||
{t('room.shareTitle')}
|
||||
</h3>
|
||||
<p className="text-xs text-th-text-s">{t('room.shareDescription')}</p>
|
||||
|
||||
{/* User search */}
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<UserPlus size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={shareSearch}
|
||||
onChange={e => searchUsers(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('room.shareSearchPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
{shareResults.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-th-card border border-th-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
{shareResults.map(u => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => handleShare(u.id)}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden"
|
||||
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||
>
|
||||
{u.avatar_image ? (
|
||||
<img src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
u.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-th-text truncate">{u.name}</div>
|
||||
<div className="text-xs text-th-text-s truncate">{u.email}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shared users list */}
|
||||
{sharedUsers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{sharedUsers.map(u => (
|
||||
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden"
|
||||
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||
>
|
||||
{u.avatar_image ? (
|
||||
<img src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
u.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-th-text truncate">{u.name}</div>
|
||||
<div className="text-xs text-th-text-s truncate">{u.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUnshare(u.id)}
|
||||
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors flex-shrink-0"
|
||||
title={t('room.shareRemove')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-th-border">
|
||||
<button type="submit" disabled={saving} className="btn-primary">
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
|
||||
Reference in New Issue
Block a user