Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
396b6c07c7 | ||
|
|
35b4a72c8b | ||
|
|
ba916e0f65 | ||
|
|
e5d0672059 | ||
|
|
5b4c69693d | ||
|
|
f9b0429a2e |
21
CHANGELOG.md
21
CHANGELOG.md
@@ -1,3 +1,24 @@
|
||||
## [3.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.5.0...v3.6.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add socket listener and polling for approval notifications ([35b4a72](https://github.com/antialias/soroban-abacus-flashcards/commit/35b4a72c8b2f80a74b5d2fe02b048d4ec4d1d6f2))
|
||||
|
||||
## [3.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.4.0...v3.5.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* replace access mode dropdown with visual button grid ([e5d0672](https://github.com/antialias/soroban-abacus-flashcards/commit/e5d067205989d7c3105998dcd7d67fd0408f332c))
|
||||
|
||||
## [3.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.1...v3.4.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add waiting state for approval requests in JoinRoomModal ([f9b0429](https://github.com/antialias/soroban-abacus-flashcards/commit/f9b0429a2e2d22944acba66009dd87a9d9eb28c2))
|
||||
|
||||
## [3.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.0...v3.3.1) (2025-10-14)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import { useGetRoomByCode, useJoinRoom, useRoomData } from '@/hooks/useRoomData'
|
||||
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
|
||||
|
||||
@@ -351,6 +352,61 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Socket listener and polling for approval notifications
|
||||
useEffect(() => {
|
||||
if (!approvalRequested || !targetRoomData) return
|
||||
|
||||
console.log('[Join Page] Setting up approval listeners for room:', targetRoomData.id)
|
||||
|
||||
// Socket listener for real-time approval notification
|
||||
const socket = io({ path: '/api/socket' })
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[Join Page] Socket connected')
|
||||
})
|
||||
|
||||
socket.on('join-request-approved', (data: { roomId: string; requestId: string }) => {
|
||||
console.log('[Join Page] Request approved via socket!', data)
|
||||
if (data.roomId === targetRoomData.id) {
|
||||
console.log('[Join Page] Joining room automatically...')
|
||||
handleJoin(targetRoomData.id)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('[Join Page] Socket connection error:', error)
|
||||
})
|
||||
|
||||
// Polling fallback - check every 5 seconds
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
console.log('[Join Page] Polling for approval status...')
|
||||
const res = await fetch(`/api/arcade/rooms/${targetRoomData.id}/join-requests`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
// Check if any request for this user was approved
|
||||
const approvedRequest = data.requests?.find(
|
||||
(r: { status: string }) => r.status === 'approved'
|
||||
)
|
||||
if (approvedRequest) {
|
||||
console.log('[Join Page] Request approved via polling! Joining room...')
|
||||
clearInterval(pollInterval)
|
||||
socket.disconnect()
|
||||
handleJoin(targetRoomData.id)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Join Page] Failed to poll join requests:', err)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
return () => {
|
||||
console.log('[Join Page] Cleaning up approval listeners')
|
||||
socket.disconnect()
|
||||
clearInterval(pollInterval)
|
||||
}
|
||||
}, [approvalRequested, targetRoomData, handleJoin])
|
||||
|
||||
// Only show error page for non-password and non-approval errors
|
||||
if (error && !showPasswordPrompt && !showApprovalPrompt) {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import type { schema } from '@/db'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
|
||||
export interface JoinRoomModalProps {
|
||||
/**
|
||||
@@ -32,6 +33,7 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
const [roomInfo, setRoomInfo] = useState<schema.ArcadeRoom | null>(null)
|
||||
const [needsPassword, setNeedsPassword] = useState(false)
|
||||
const [needsApproval, setNeedsApproval] = useState(false)
|
||||
const [approvalRequested, setApprovalRequested] = useState(false)
|
||||
|
||||
const handleClose = () => {
|
||||
setCode('')
|
||||
@@ -41,6 +43,7 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
setRoomInfo(null)
|
||||
setNeedsPassword(false)
|
||||
setNeedsApproval(false)
|
||||
setApprovalRequested(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -118,6 +121,8 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
if (!roomInfo) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/arcade/rooms/${roomInfo.id}/join-requests`, {
|
||||
method: 'POST',
|
||||
@@ -129,16 +134,84 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
throw new Error(errorData.error || 'Failed to request access')
|
||||
}
|
||||
|
||||
// Success!
|
||||
alert('Access request sent! The host will review your request.')
|
||||
handleClose()
|
||||
// Success! Show waiting state
|
||||
setApprovalRequested(true)
|
||||
setIsLoading(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to request access')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Socket listener and polling for approval notifications
|
||||
useEffect(() => {
|
||||
if (!approvalRequested || !roomInfo) return
|
||||
|
||||
console.log('[JoinRoomModal] Setting up approval listeners for room:', roomInfo.id)
|
||||
|
||||
// Socket listener for real-time approval notification
|
||||
const socket = io({ path: '/api/socket' })
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[JoinRoomModal] Socket connected')
|
||||
})
|
||||
|
||||
socket.on('join-request-approved', async (data: { roomId: string; requestId: string }) => {
|
||||
console.log('[JoinRoomModal] Request approved via socket!', data)
|
||||
if (data.roomId === roomInfo.id) {
|
||||
console.log('[JoinRoomModal] Joining room automatically...')
|
||||
try {
|
||||
await joinRoom(roomInfo.id)
|
||||
handleClose()
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join room')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('[JoinRoomModal] Socket connection error:', error)
|
||||
})
|
||||
|
||||
// Polling fallback - check every 5 seconds
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
console.log('[JoinRoomModal] Polling for approval status...')
|
||||
const res = await fetch(`/api/arcade/rooms/${roomInfo.id}/join-requests`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
// Check if any request for this user was approved
|
||||
const approvedRequest = data.requests?.find(
|
||||
(r: { status: string }) => r.status === 'approved'
|
||||
)
|
||||
if (approvedRequest) {
|
||||
console.log('[JoinRoomModal] Request approved via polling! Joining room...')
|
||||
clearInterval(pollInterval)
|
||||
socket.disconnect()
|
||||
try {
|
||||
await joinRoom(roomInfo.id)
|
||||
handleClose()
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join room')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[JoinRoomModal] Failed to poll join requests:', err)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
return () => {
|
||||
console.log('[JoinRoomModal] Cleaning up approval listeners')
|
||||
socket.disconnect()
|
||||
clearInterval(pollInterval)
|
||||
}
|
||||
}, [approvalRequested, roomInfo, joinRoom, handleClose, onSuccess])
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose}>
|
||||
<div
|
||||
@@ -165,7 +238,9 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
}}
|
||||
>
|
||||
{needsApproval
|
||||
? 'This room requires host approval. Send a request to join?'
|
||||
? approvalRequested
|
||||
? 'Your request has been sent to the room moderator.'
|
||||
: 'This room requires host approval. Send a request to join?'
|
||||
: needsPassword
|
||||
? 'This room is password protected'
|
||||
: 'Enter the 6-character room code'}
|
||||
@@ -174,110 +249,192 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
{needsApproval ? (
|
||||
// Approval request UI
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<strong>{roomInfo?.name}</strong>
|
||||
</p>
|
||||
<p style={{ fontSize: '13px', color: 'rgba(156, 163, 175, 1)' }}>
|
||||
Code: {roomInfo?.code}
|
||||
</p>
|
||||
</div>
|
||||
{approvalRequested ? (
|
||||
// Waiting for approval state
|
||||
<>
|
||||
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>⏳</div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: 'rgba(96, 165, 250, 1)',
|
||||
}}
|
||||
>
|
||||
Waiting for Approval
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(248, 113, 113, 1)',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<strong>{roomInfo?.name}</strong>
|
||||
</p>
|
||||
<p style={{ fontSize: '13px', color: 'rgba(156, 163, 175, 1)' }}>
|
||||
Code: {roomInfo?.code}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'rgba(75, 85, 99, 0.3)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
<p
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(156, 163, 175, 1)',
|
||||
textAlign: 'center',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
You'll be able to join once the host approves your request. You can close this
|
||||
dialog and check back later.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: 'rgba(75, 85, 99, 0.3)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) {
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRequestAccess}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: isLoading
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
|
||||
color: isLoading ? 'rgba(156, 163, 175, 1)' : 'rgba(255, 255, 255, 1)',
|
||||
border: isLoading
|
||||
? '2px solid rgba(75, 85, 99, 0.5)'
|
||||
: '2px solid rgba(59, 130, 246, 0.6)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send Request'}
|
||||
</button>
|
||||
</div>
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// Initial request prompt
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<strong>{roomInfo?.name}</strong>
|
||||
</p>
|
||||
<p style={{ fontSize: '13px', color: 'rgba(156, 163, 175, 1)' }}>
|
||||
Code: {roomInfo?.code}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(248, 113, 113, 1)',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'rgba(75, 85, 99, 0.3)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRequestAccess}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: isLoading
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
|
||||
color: isLoading ? 'rgba(156, 163, 175, 1)' : 'rgba(255, 255, 255, 1)',
|
||||
border: isLoading
|
||||
? '2px solid rgba(75, 85, 99, 0.5)'
|
||||
: '2px solid rgba(59, 130, 246, 0.6)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send Request'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Standard join form
|
||||
@@ -323,7 +480,6 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
}}
|
||||
placeholder="Enter password"
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
|
||||
@@ -1333,31 +1333,81 @@ export function ModerationPanel({
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<select
|
||||
value={accessMode}
|
||||
onChange={(e) => {
|
||||
setAccessMode(e.target.value)
|
||||
setShowPasswordInput(e.target.value === 'password')
|
||||
}}
|
||||
{/* Access mode button grid */}
|
||||
<div
|
||||
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',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '8px',
|
||||
marginBottom: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="open">🌐 Open - Anyone can join</option>
|
||||
<option value="password">🔑 Password Protected</option>
|
||||
<option value="approval-only">✋ Approval Required</option>
|
||||
<option value="restricted">🚫 Restricted - Invitation only</option>
|
||||
<option value="locked">🔒 Locked - No new members</option>
|
||||
<option value="retired">🏁 Retired - Room closed</option>
|
||||
</select>
|
||||
{[
|
||||
{ value: 'open', emoji: '🌐', label: 'Open', desc: 'Anyone' },
|
||||
{ value: 'password', emoji: '🔑', label: 'Password', desc: 'With key' },
|
||||
{ value: 'approval-only', emoji: '✋', label: 'Approval', desc: 'Request' },
|
||||
{
|
||||
value: 'restricted',
|
||||
emoji: '🚫',
|
||||
label: 'Restricted',
|
||||
desc: 'Invite only',
|
||||
},
|
||||
{ value: 'locked', emoji: '🔒', label: 'Locked', desc: 'No members' },
|
||||
{ value: 'retired', emoji: '🏁', label: 'Retired', desc: 'Closed' },
|
||||
].map((mode) => (
|
||||
<button
|
||||
key={mode.value}
|
||||
type="button"
|
||||
disabled={actionLoading === 'update-settings'}
|
||||
onClick={() => {
|
||||
setAccessMode(mode.value)
|
||||
setShowPasswordInput(mode.value === 'password')
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background:
|
||||
accessMode === mode.value
|
||||
? 'rgba(253, 186, 116, 0.15)'
|
||||
: 'rgba(255, 255, 255, 0.05)',
|
||||
border:
|
||||
accessMode === mode.value
|
||||
? '2px solid rgba(253, 186, 116, 0.6)'
|
||||
: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '8px',
|
||||
color:
|
||||
accessMode === mode.value
|
||||
? 'rgba(253, 186, 116, 1)'
|
||||
: 'rgba(209, 213, 219, 0.8)',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
|
||||
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (actionLoading !== 'update-settings' && accessMode !== mode.value) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)'
|
||||
e.currentTarget.style.borderColor = 'rgba(253, 186, 116, 0.4)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (accessMode !== mode.value) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'
|
||||
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '18px' }}>{mode.emoji}</span>
|
||||
<div style={{ textAlign: 'left', flex: 1, lineHeight: '1.2' }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: '600' }}>{mode.label}</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.7 }}>{mode.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Password input (conditional) */}
|
||||
{(accessMode === 'password' || showPasswordInput) && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "3.3.1",
|
||||
"version": "3.6.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user