Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa6cea07df | ||
|
|
ebe123ed7e | ||
|
|
9afd3a7e92 | ||
|
|
efb9c37380 | ||
|
|
c00cfa3de0 | ||
|
|
da53e084f0 | ||
|
|
22df1b0b66 | ||
|
|
c0680cad0f | ||
|
|
0fef1dc9db | ||
|
|
c92ff3971c |
34
CHANGELOG.md
34
CHANGELOG.md
@@ -1,3 +1,37 @@
|
||||
## [4.14.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.14.5...v4.14.6) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* replace native alerts with inline confirmations in ModerationPanel ([ebe123e](https://github.com/antialias/soroban-abacus-flashcards/commit/ebe123ed7edf24fbc7b8765ed709455a8513d6d5))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add UI style guide documenting no native alerts rule ([9afd3a7](https://github.com/antialias/soroban-abacus-flashcards/commit/9afd3a7e925fddb76fa587747881b61f7cb077a5))
|
||||
|
||||
## [4.14.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.14.4...v4.14.5) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **rooms:** add real-time ownership transfer updates via WebSocket ([c00cfa3](https://github.com/antialias/soroban-abacus-flashcards/commit/c00cfa3de011720f3399fa340182b347f7e0d456))
|
||||
|
||||
## [4.14.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.14.3...v4.14.4) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** add host-only game selection with clear messaging ([22df1b0](https://github.com/antialias/soroban-abacus-flashcards/commit/22df1b0b661efe69fac1a6bd716531c904757412))
|
||||
* **arcade:** add host-only game selection with clear messaging ([c0680ca](https://github.com/antialias/soroban-abacus-flashcards/commit/c0680cad0fa26af0933e93a06c50317bf443cc7d))
|
||||
|
||||
## [4.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.14.2...v4.14.3) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** add qpdf for PDF linearization and validation ([c92ff39](https://github.com/antialias/soroban-abacus-flashcards/commit/c92ff3971c853e4e55ccd632ff3ee292fcce8315))
|
||||
|
||||
## [4.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.14.1...v4.14.2) (2025-10-19)
|
||||
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ RUN turbo build --filter=@soroban/web
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python, pip, build tools for better-sqlite3, and Typst (needed at runtime)
|
||||
RUN apk add --no-cache python3 py3-pip py3-setuptools make g++ typst
|
||||
# Install Python, pip, build tools for better-sqlite3, Typst, and qpdf (needed at runtime)
|
||||
RUN apk add --no-cache python3 py3-pip py3-setuptools make g++ typst qpdf
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
|
||||
94
apps/web/.claude/UI_STYLE_GUIDE.md
Normal file
94
apps/web/.claude/UI_STYLE_GUIDE.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# UI Style Guide
|
||||
|
||||
## Confirmations and Dialogs
|
||||
|
||||
**NEVER use native browser dialogs:**
|
||||
- ❌ `alert()`
|
||||
- ❌ `confirm()`
|
||||
- ❌ `prompt()`
|
||||
|
||||
**ALWAYS use inline React-based confirmations:**
|
||||
- Show confirmation UI in-place using React state
|
||||
- Provide Cancel and Confirm buttons
|
||||
- Use descriptive warning messages with appropriate emoji (⚠️)
|
||||
- Follow the Panda CSS styling system
|
||||
- Match the visual style of the surrounding UI
|
||||
|
||||
### Pattern: Inline Confirmation
|
||||
|
||||
```typescript
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
|
||||
{!confirming ? (
|
||||
<button onClick={() => setConfirming(true)}>
|
||||
Delete Item
|
||||
</button>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ /* warning styling */ }}>
|
||||
⚠️ Are you sure you want to delete this item?
|
||||
</div>
|
||||
<div style={{ /* description styling */ }}>
|
||||
This action cannot be undone.
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button onClick={() => setConfirming(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleDelete}>
|
||||
Confirm Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### Real Examples
|
||||
|
||||
See `/src/components/nav/ModerationPanel.tsx` for production examples:
|
||||
- Transfer ownership confirmation (lines 1793-1929)
|
||||
- Unban user confirmation (shows inline warning with Cancel/Confirm)
|
||||
|
||||
### Why This Pattern?
|
||||
|
||||
1. **Consistency**: Native dialogs look different across browsers and platforms
|
||||
2. **Control**: We can style, position, and enhance confirmations to match our design
|
||||
3. **Accessibility**: We can add proper ARIA attributes and keyboard navigation
|
||||
4. **UX**: Users stay in context rather than being interrupted by modal dialogs
|
||||
5. **Testing**: Inline confirmations are easier to test than native browser dialogs
|
||||
|
||||
### Migration Checklist
|
||||
|
||||
When replacing native dialogs:
|
||||
- [ ] Add state variable for confirmation (e.g., `const [confirming, setConfirming] = useState(false)`)
|
||||
- [ ] Remove the `confirm()` or `alert()` call from the handler
|
||||
- [ ] Replace the original UI with conditional rendering
|
||||
- [ ] Show initial state with primary action button
|
||||
- [ ] Show confirmation state with warning message + Cancel/Confirm buttons
|
||||
- [ ] Ensure Cancel button resets state: `onClick={() => setConfirming(false)}`
|
||||
- [ ] Ensure Confirm button performs action and resets state
|
||||
- [ ] Add loading states if the action is async
|
||||
- [ ] Style to match surrounding UI using Panda CSS
|
||||
|
||||
## Styling System
|
||||
|
||||
This project uses **Panda CSS**, not Tailwind CSS.
|
||||
|
||||
- ❌ Never use Tailwind utility classes (e.g., `className="bg-blue-500"`)
|
||||
- ✅ Always use Panda CSS `css()` function
|
||||
- ✅ Use Panda's token system (defined in `panda.config.ts`)
|
||||
|
||||
See `.claude/CLAUDE.md` for complete Panda CSS documentation.
|
||||
|
||||
## Emoji Usage
|
||||
|
||||
Emojis are used liberally throughout the UI for visual communication:
|
||||
- 👑 Host/owner status
|
||||
- ⏳ Waiting states
|
||||
- ⚠️ Warnings and confirmations
|
||||
- ✅ Success states
|
||||
- ❌ Error states
|
||||
- 👀 Spectating mode
|
||||
- 🎮 Gaming context
|
||||
|
||||
Use emojis to enhance clarity, not replace text.
|
||||
@@ -101,7 +101,8 @@
|
||||
"WebFetch(domain:abaci.one)",
|
||||
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run ID: \\(.databaseId)\"\"')",
|
||||
"Bash(node -e:*)",
|
||||
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run \\(.databaseId)\"\"')"
|
||||
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run \\(.databaseId)\"\"')",
|
||||
"Bash(do ssh nas.home.network '/usr/local/bin/docker inspect soroban-abacus-flashcards --format=\"\"{{index .Config.Labels \\\"\"org.opencontainers.image.revision\\\"\"}}\"\"')"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -24,7 +24,8 @@ function getBuildInfo() {
|
||||
const gitCommitShort = process.env.GIT_COMMIT_SHORT || exec('git rev-parse --short HEAD')
|
||||
const gitBranch = process.env.GIT_BRANCH || exec('git rev-parse --abbrev-ref HEAD')
|
||||
const gitTag = process.env.GIT_TAG || exec('git describe --tags --exact-match 2>/dev/null')
|
||||
const gitDirty = process.env.GIT_DIRTY === 'true' || exec('git diff --quiet || echo "dirty"') === 'dirty'
|
||||
const gitDirty =
|
||||
process.env.GIT_DIRTY === 'true' || exec('git diff --quiet || echo "dirty"') === 'dirty'
|
||||
|
||||
const packageJson = require('../package.json')
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { GAMES_CONFIG } from '@/components/GameSelector'
|
||||
import type { GameType } from '@/components/GameSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
@@ -23,7 +25,9 @@ import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
|
||||
export default function RoomPage() {
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { mutate: setRoomGame } = useSetRoomGame()
|
||||
const [permissionError, setPermissionError] = useState<string | null>(null)
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
@@ -74,9 +78,27 @@ export default function RoomPage() {
|
||||
|
||||
// Show game selection if no game is set
|
||||
if (!roomData.gameName) {
|
||||
// Determine if current user is the host
|
||||
const currentMember = roomData.members.find((m) => m.userId === viewerId)
|
||||
const isHost = currentMember?.isCreator === true
|
||||
const hostMember = roomData.members.find((m) => m.isCreator)
|
||||
|
||||
const handleGameSelect = (gameType: GameType) => {
|
||||
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
|
||||
|
||||
// Check if user is host before allowing selection
|
||||
if (!isHost) {
|
||||
setPermissionError(
|
||||
`Only the room host can select a game. Ask ${hostMember?.displayName || 'the host'} to choose.`
|
||||
)
|
||||
// Clear error after 5 seconds
|
||||
setTimeout(() => setPermissionError(null), 5000)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear any previous errors
|
||||
setPermissionError(null)
|
||||
|
||||
// All games are now in the registry
|
||||
if (hasGame(gameType)) {
|
||||
const gameDef = getGame(gameType)
|
||||
@@ -86,10 +108,21 @@ export default function RoomPage() {
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Selecting registry game:', gameType)
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: gameType,
|
||||
})
|
||||
setRoomGame(
|
||||
{
|
||||
roomId: roomData.id,
|
||||
gameName: gameType,
|
||||
},
|
||||
{
|
||||
onError: (error: any) => {
|
||||
console.error('[RoomPage] Failed to set game:', error)
|
||||
setPermissionError(
|
||||
error.message || 'Failed to select game. Only the host can change games.'
|
||||
)
|
||||
setTimeout(() => setPermissionError(null), 5000)
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -119,13 +152,70 @@ export default function RoomPage() {
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '8',
|
||||
mb: '4',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Choose a Game
|
||||
</h1>
|
||||
|
||||
{/* Host info and permission messaging */}
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
mb: '6',
|
||||
})}
|
||||
>
|
||||
{isHost ? (
|
||||
<div
|
||||
className={css({
|
||||
background: 'rgba(34, 197, 94, 0.1)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
color: '#86efac',
|
||||
fontSize: 'sm',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
👑 You're the room host. Select a game to start playing.
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
background: 'rgba(234, 179, 8, 0.1)',
|
||||
border: '1px solid rgba(234, 179, 8, 0.3)',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
color: '#fde047',
|
||||
fontSize: 'sm',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
⏳ Waiting for {hostMember?.displayName || 'the host'} to select a game...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permission error message */}
|
||||
{permissionError && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
color: '#fca5a5',
|
||||
fontSize: 'sm',
|
||||
textAlign: 'center',
|
||||
mt: '3',
|
||||
})}
|
||||
>
|
||||
⚠️ {permissionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
@@ -138,21 +228,22 @@ export default function RoomPage() {
|
||||
{/* Legacy games */}
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]: [string, any]) => {
|
||||
const isAvailable = !('available' in config) || config.available !== false
|
||||
const isDisabled = !isHost || !isAvailable
|
||||
return (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={!isAvailable}
|
||||
disabled={isDisabled}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.4 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
_hover: isDisabled
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
@@ -193,21 +284,22 @@ export default function RoomPage() {
|
||||
{/* Registry games */}
|
||||
{getAllGames().map((gameDef) => {
|
||||
const isAvailable = gameDef.manifest.available
|
||||
const isDisabled = !isHost || !isAvailable
|
||||
return (
|
||||
<button
|
||||
key={gameDef.manifest.name}
|
||||
onClick={() => handleGameSelect(gameDef.manifest.name)}
|
||||
disabled={!isAvailable}
|
||||
disabled={isDisabled}
|
||||
className={css({
|
||||
background: gameDef.manifest.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: gameDef.manifest.borderColor,
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.4 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
_hover: isDisabled
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
|
||||
@@ -114,6 +114,9 @@ export function ModerationPanel({
|
||||
null
|
||||
)
|
||||
|
||||
// Transfer ownership confirmation state
|
||||
const [confirmingTransferOwnership, setConfirmingTransferOwnership] = useState(false)
|
||||
|
||||
// Auto-switch to Members tab when focusedUserId is provided
|
||||
useEffect(() => {
|
||||
if (isOpen && focusedUserId) {
|
||||
@@ -171,8 +174,6 @@ export function ModerationPanel({
|
||||
}, [isOpen, roomId, members])
|
||||
|
||||
const handleKick = async (userId: string) => {
|
||||
if (!confirm('Kick this player from the room?')) return
|
||||
|
||||
setActionLoading(`kick-${userId}`)
|
||||
try {
|
||||
const res = await fetch(`/api/arcade/rooms/${roomId}/kick`, {
|
||||
@@ -414,10 +415,9 @@ export function ModerationPanel({
|
||||
const newOwner = members.find((m) => m.userId === selectedNewOwner)
|
||||
if (!newOwner) return
|
||||
|
||||
if (!confirm(`Transfer ownership to ${newOwner.displayName}? You will no longer be the host.`))
|
||||
return
|
||||
|
||||
setConfirmingTransferOwnership(false)
|
||||
setActionLoading('transfer-ownership')
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/arcade/rooms/${roomId}/transfer-ownership`, {
|
||||
method: 'POST',
|
||||
@@ -436,6 +436,7 @@ export function ModerationPanel({
|
||||
showError(err instanceof Error ? err.message : 'Failed to transfer ownership')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
setSelectedNewOwner('') // Reset selection
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1789,61 +1790,143 @@ export function ModerationPanel({
|
||||
Transfer host privileges to another member. You will no longer be the host.
|
||||
</p>
|
||||
|
||||
<select
|
||||
value={selectedNewOwner}
|
||||
onChange={(e) => setSelectedNewOwner(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '6px',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
marginBottom: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="">Select new owner...</option>
|
||||
{otherMembers.map((member) => (
|
||||
<option key={member.userId} value={member.userId}>
|
||||
{member.displayName}
|
||||
{member.isOnline ? ' (Online)' : ' (Offline)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!confirmingTransferOwnership ? (
|
||||
<>
|
||||
<select
|
||||
value={selectedNewOwner}
|
||||
onChange={(e) => setSelectedNewOwner(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '6px',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
marginBottom: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="">Select new owner...</option>
|
||||
{otherMembers.map((member) => (
|
||||
<option key={member.userId} value={member.userId}>
|
||||
{member.displayName}
|
||||
{member.isOnline ? ' (Online)' : ' (Offline)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTransferOwnership}
|
||||
disabled={!selectedNewOwner || actionLoading === 'transfer-ownership'}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background:
|
||||
!selectedNewOwner || actionLoading === 'transfer-ownership'
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'linear-gradient(135deg, rgba(251, 146, 60, 0.8), rgba(249, 115, 22, 0.8))',
|
||||
color: 'white',
|
||||
border:
|
||||
!selectedNewOwner || actionLoading === 'transfer-ownership'
|
||||
? '1px solid rgba(75, 85, 99, 0.5)'
|
||||
: '1px solid rgba(251, 146, 60, 0.6)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor:
|
||||
!selectedNewOwner || actionLoading === 'transfer-ownership'
|
||||
? 'not-allowed'
|
||||
: 'pointer',
|
||||
opacity:
|
||||
!selectedNewOwner || actionLoading === 'transfer-ownership' ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{actionLoading === 'transfer-ownership'
|
||||
? 'Transferring...'
|
||||
: 'Transfer Ownership'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingTransferOwnership(true)}
|
||||
disabled={!selectedNewOwner}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background: !selectedNewOwner
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'linear-gradient(135deg, rgba(251, 146, 60, 0.8), rgba(249, 115, 22, 0.8))',
|
||||
color: 'white',
|
||||
border: !selectedNewOwner
|
||||
? '1px solid rgba(75, 85, 99, 0.5)'
|
||||
: '1px solid rgba(251, 146, 60, 0.6)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: !selectedNewOwner ? 'not-allowed' : 'pointer',
|
||||
opacity: !selectedNewOwner ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
Transfer Ownership
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: 'rgba(251, 191, 36, 1)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
⚠️ Confirm Transfer to{' '}
|
||||
{members.find((m) => m.userId === selectedNewOwner)?.displayName}?
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
You will no longer be the host and will lose moderation privileges. This
|
||||
cannot be undone.
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingTransferOwnership(false)}
|
||||
disabled={actionLoading === 'transfer-ownership'}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
background: 'rgba(75, 85, 99, 0.3)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
border: '1px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor:
|
||||
actionLoading === 'transfer-ownership' ? 'not-allowed' : 'pointer',
|
||||
opacity: actionLoading === 'transfer-ownership' ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (actionLoading !== 'transfer-ownership') {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (actionLoading !== 'transfer-ownership') {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTransferOwnership}
|
||||
disabled={actionLoading === 'transfer-ownership'}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
background:
|
||||
actionLoading === 'transfer-ownership'
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'linear-gradient(135deg, rgba(251, 146, 60, 0.8), rgba(249, 115, 22, 0.8))',
|
||||
color: 'white',
|
||||
border:
|
||||
actionLoading === 'transfer-ownership'
|
||||
? '1px solid rgba(75, 85, 99, 0.5)'
|
||||
: '1px solid rgba(251, 146, 60, 0.6)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor:
|
||||
actionLoading === 'transfer-ownership' ? 'not-allowed' : 'pointer',
|
||||
opacity: actionLoading === 'transfer-ownership' ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{actionLoading === 'transfer-ownership'
|
||||
? 'Transferring...'
|
||||
: 'Confirm Transfer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -464,6 +464,24 @@ export function useRoomData() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOwnershipTransferred = (data: {
|
||||
roomId: string
|
||||
oldOwnerId: string
|
||||
newOwnerId: string
|
||||
newOwnerName: string
|
||||
members: RoomMember[]
|
||||
}) => {
|
||||
if (data.roomId === roomData?.id) {
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
members: data.members,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('room-joined', handleRoomJoined)
|
||||
socket.on('member-joined', handleMemberJoined)
|
||||
socket.on('member-left', handleMemberLeft)
|
||||
@@ -474,6 +492,7 @@ export function useRoomData() {
|
||||
socket.on('room-invitation-received', handleInvitationReceived)
|
||||
socket.on('join-request-submitted', handleJoinRequestSubmitted)
|
||||
socket.on('room-game-changed', handleRoomGameChanged)
|
||||
socket.on('ownership-transferred', handleOwnershipTransferred)
|
||||
|
||||
return () => {
|
||||
socket.off('room-joined', handleRoomJoined)
|
||||
@@ -486,6 +505,7 @@ export function useRoomData() {
|
||||
socket.off('room-invitation-received', handleInvitationReceived)
|
||||
socket.off('join-request-submitted', handleJoinRequestSubmitted)
|
||||
socket.off('room-game-changed', handleRoomGameChanged)
|
||||
socket.off('ownership-transferred', handleOwnershipTransferred)
|
||||
}
|
||||
}, [socket, roomData?.id, queryClient])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.14.2",
|
||||
"version": "4.14.6",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user