diff --git a/.gitignore b/.gitignore index 90c2a1c..20fd261 100644 --- a/.gitignore +++ b/.gitignore @@ -138,7 +138,6 @@ test_*.png *.debug.png # Recordings and media files -recordings/ hls_*/ # Thumbnails @@ -171,3 +170,5 @@ Thumbs.db *.bak *.backup android/app/debug/output-metadata.json + +tsconfig.tsbuildinfo diff --git a/eslint.config.mts b/eslint.config.mts index e21fb18..9a6aa62 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -27,11 +27,20 @@ export default defineConfig([ '.yarn/', 'android/', 'source/generated/', + 'web2/dist/', ]), { extends: fixupConfigRules(compat.extends('plugin:import/recommended')), - files: ['source/**/*.ts', 'eslint.config.mts', 'scripts/**/*.ts', 'prisma.config.ts', 'capacitor.config.ts'], + files: [ + 'source/**/*.ts', + 'eslint.config.mts', + 'scripts/**/*.ts', + 'prisma.config.ts', + 'capacitor.config.ts', + 'web2/**/*.ts', + 'web2/**/*.tsx', + ], plugins: { '@stylistic': stylistic, diff --git a/package.json b/package.json index 7729641..247114a 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "packageManager": "yarn@4.17.0", "workspaces": [ ".", - "web" + "web", + "web2" ], "scripts": { "start": "node ./compiled/camera.js", @@ -17,8 +18,8 @@ "build:server": "yarn build:server:safe", "build:server:safe": "copyfiles -u 1 \"source/generated/**/*\" \"compiled-temp\" && tsc --project tsconfig.build.json --outDir compiled-temp && tsx --tsconfig tsconfig.build.json scripts/safeReplace.ts", "build:server:force": "copyfiles -u 1 \"source/generated/**/*\" \"compiled\" && tsc --project tsconfig.build.json", - "build:client": "dotenvx run -- yarn workspace web build", - "dev:client": "dotenvx run -- yarn workspace web vite build --watch", + "build:client": "dotenvx run -- yarn workspace web2 build", + "dev:client": "cross-env NODE_ENV=development dotenvx run -- yarn workspace web2 vite build --watch --mode development --minify false", "dev:server": "nodemon --watch source --ext ts --exec \"tsx --tsconfig tsconfig.build.json ./source/camera.ts\"", "db:generate": "yarn prisma generate", "db:push": "yarn prisma db push", @@ -26,10 +27,8 @@ "db:migrate:dev": "yarn prisma migrate dev", "db:migrate:resolve": "yarn prisma migrate resolve", "db:migrate:deploy": "yarn prisma migrate deploy", - "lint": "yarn lint:server || true && yarn lint:client", - "lint:fix": "yarn lint:server --fix && yarn lint:client --fix", - "lint:server": "yarn eslint", - "lint:client": "yarn workspace web eslint", + "lint": "yarn eslint", + "lint:fix": "yarn lint --fix", "format:server": "prettier --check source/", "format:server:fix": "prettier --write source/", "format:client": "prettier --check web/src", @@ -50,12 +49,13 @@ "@types/express-session": "1.19.0", "@types/greenlock-express": "4.0.3", "@types/jsonwebtoken": "9.0.10", - "@types/node": "25.6.0", + "@types/node": "25.9.7", "@types/session-file-store": "1.2.6", "@types/web-push": "3.6.4", "@typescript-eslint/eslint-plugin": "8.63.0", "@typescript-eslint/parser": "8.63.0", "concurrently": "10.0.3", + "cross-env": "10.1.0", "eslint": "10.3.0", "eslint-config-prettier": "10.1.8", "eslint-plugin-import": "2.32.0", diff --git a/source/camera.ts b/source/camera.ts index f1c1954..d530885 100644 --- a/source/camera.ts +++ b/source/camera.ts @@ -62,7 +62,6 @@ export const notifyLogPath = path.join( ) export const socketDisconnectMap = new Map() -// Ensure logs directory exists const motionLogsDir = path.dirname(motionLogPath) if (!fs.existsSync(motionLogsDir)) { fs.mkdirSync(motionLogsDir, { recursive: true }) @@ -108,6 +107,7 @@ export interface RequestWithUser extends express.Request { } const streamStates: Record = {} +const streamStateInitializations = new Map>() export const motionWatcherTimeouts = new Map() @@ -119,36 +119,7 @@ export async function setupStreamMotionMonitoring(streamId?: string) { `[${streamId}] Monitoring started at ${new Date().toLocaleString()}`, ) - if (!streamStates[streamId]) { - const persistedStates = await loadPersistedStreamStates() - streamStates[streamId] = { - segmentTimestampMap: new Map(), - processingSegment: false, - notificationSent: false, - motionRecordingActive: false, - motionRecordingTimeoutAt: 0, - motionSegments: [], - flushingSegments: [], - recentSegments: [], - flushedSegments: [], - motionPaused: persistedStates[streamId]?.motionPaused ?? false, - startupTime: Date.now(), - savingInProgress: false, - currentSaveProcess: null, - saveRetryCount: 0, - motionStartedAt: 0, - lastSegmentProcessAt: 0, - recordingTitle: `motion_${new Date().toISOString().replace(/[:.]/g, '-')}.mp4`, - flushRecordings: [], - nextFlushNumber: 1, - cleaningUp: false, - cancelFlush: false, - lowSpaceNotified: false, - lastNotifiedRestartCooldownAt: 0, - currentRecordingMotionTimestamps: [], - lastPlaylistUpdatedAt: 0, - } - } + await initializeStreamState(streamId) // --- Motion Detection Watcher --- watchers.set( @@ -186,6 +157,7 @@ export async function setupStreamMotionMonitoring(streamId?: string) { 'New segment observed while processing previous segment, rescheduling processing to avoid busy loop.', ) setTimeout(() => processSegment(segmentPath), 300) + return } state.processingSegment = true @@ -350,6 +322,10 @@ export async function setupStreamMotionMonitoring(streamId?: string) { motionWatcherTimeouts.set( streamId, setTimeout(async () => { + if (!dynamicStreams[streamId]) { + motionWatcherTimeouts.delete(streamId) + return + } if ( !streamStates[streamId] && !dynamicStreams[streamId].restartInProgress @@ -658,7 +634,7 @@ loadStreamsFromDb() if (process.env.API_ENV === 'production') return setTimeout(() => { open(`http://localhost:${port}`) - }, 1500) + }, 3000) }) }) @@ -761,7 +737,9 @@ app.use( cors({ origin: (origin, callback) => { const allowedOrigins = ( - process.env.API_ENV === 'production' ? [] : ['http://localhost:3000'] + process.env.API_ENV === 'production' + ? [] + : [`http://localhost:${process.env.PORT ?? 3000}`] ).concat(config.domains ?? []) if (!origin || allowedOrigins.includes(origin)) { callback(null, true) @@ -855,23 +833,39 @@ const thumbnailLimiter = rateLimit({ legacyHeaders: false, }) +const staticResourceLimiter = rateLimit({ + validate: { ip: false }, + windowMs: 60 * 1000, // 1 minute + max: 240, + standardHeaders: true, + legacyHeaders: false, +}) + // --- Static & API Routes --- -app.use(express.static(path.join(__dirname, '..', 'web', 'dist'))) +app.use(express.static(path.join(__dirname, '..', 'web2', 'dist'))) app.use( '/recordings/thumbnails', thumbnailLimiter, jwtAuth, express.static(path.join(config.recordingsDirectory, 'thumbnails')), ) -app.get(/^\/(?!hls|api|recordings|signed|sounds).*/, (req, res) => { - res.sendFile(path.join(__dirname, '..', 'web', 'dist', 'index.html')) -}) +app.get( + /^\/(?!hls|api|recordings|signed|sounds).*/, + staticResourceLimiter, + (_req, res) => { + res.sendFile(path.join(__dirname, '..', 'web2', 'dist', 'index.html')) + }, +) -app.get(/^\/recordings(\/[^/]+)(\/[^/]+)?$/, (req, res) => { - res.sendFile(path.join(__dirname, '..', 'web', 'dist', 'index.html')) -}) +app.get( + /^\/recordings(\/[^/]+)(\/[^/]+)?$/, + staticResourceLimiter, + (_req, res) => { + res.sendFile(path.join(__dirname, '..', 'web2', 'dist', 'index.html')) + }, +) -app.get('/api/vapid-public-key', (req, res) => { +app.get('/api/vapid-public-key', (_req, res) => { res.json({ publicKey: config.vapid.publicKey }) }) @@ -960,36 +954,54 @@ export async function createStreamManager(stream: { } export async function initializeStreamState(streamId: string) { - streamStates[streamId] = { - segmentTimestampMap: new Map(), - processingSegment: false, - notificationSent: false, - motionRecordingActive: false, - motionRecordingTimeoutAt: 0, - motionSegments: [], - flushingSegments: [], - recentSegments: [], - flushedSegments: [], - motionPaused: - (await loadPersistedStreamState(streamId))?.motionPaused ?? false, - startupTime: Date.now(), - savingInProgress: false, - currentSaveProcess: null, - saveRetryCount: 0, - motionStartedAt: 0, - lastSegmentProcessAt: 0, - recordingTitle: `motion_${new Date().toISOString().replace(/[:.]/g, '-')}.mp4`, - flushRecordings: [], - nextFlushNumber: 1, - cleaningUp: false, - cancelFlush: false, - lowSpaceNotified: false, - lastNotifiedRestartCooldownAt: 0, - currentRecordingMotionTimestamps: [], - lastPlaylistUpdatedAt: 0, - } + const existingState = streamStates[streamId] + if (existingState) return existingState + + const existingInitialization = streamStateInitializations.get(streamId) + if (existingInitialization) return existingInitialization + + const initialization = (async () => { + const state: StreamMotionState = { + segmentTimestampMap: new Map(), + processingSegment: false, + notificationSent: false, + motionRecordingActive: false, + motionRecordingTimeoutAt: 0, + motionSegments: [], + flushingSegments: [], + recentSegments: [], + flushedSegments: [], + motionPaused: + (await loadPersistedStreamState(streamId))?.motionPaused ?? false, + startupTime: Date.now(), + savingInProgress: false, + currentSaveProcess: null, + saveRetryCount: 0, + motionStartedAt: 0, + lastSegmentProcessAt: 0, + recordingTitle: `motion_${new Date().toISOString().replace(/[:.]/g, '-')}.mp4`, + flushRecordings: [], + nextFlushNumber: 1, + cleaningUp: false, + cancelFlush: false, + lowSpaceNotified: false, + lastNotifiedRestartCooldownAt: 0, + currentRecordingMotionTimestamps: [], + lastPlaylistUpdatedAt: 0, + } + + // Publish once, after async persistence has completed, so every caller + // observes the same state object from this point onward. + streamStates[streamId] ??= state + return streamStates[streamId]! + })() - return streamStates[streamId] + streamStateInitializations.set(streamId, initialization) + try { + return await initialization + } finally { + streamStateInitializations.delete(streamId) + } } async function loadPersistedStreamState( diff --git a/source/hardwareCheck.ts b/source/hardwareCheck.ts new file mode 100644 index 0000000..1db455d --- /dev/null +++ b/source/hardwareCheck.ts @@ -0,0 +1,21 @@ +const { execSync } = require('child_process') + +let hasNvidiaHardware = false + +try { + // Execute a fast probe that exits immediately without printing outputs to the console + // Works on both Windows and Linux environments + execSync('nvidia-smi --help', { stdio: 'ignore' }) + hasNvidiaHardware = true + console.log( + '[System Probe] Nvidia hardware capability detected successfully.', + ) +} catch (error) { + hasNvidiaHardware = false + console.log( + '[System Probe] Nvidia hardware not available. Falling back to CPU/Direct methods.', + ) +} + +// Export the live state variable +export const isNvidiaAvailable = () => hasNvidiaHardware diff --git a/source/routes/motion.ts b/source/routes/motion.ts index 0086837..f238c44 100644 --- a/source/routes/motion.ts +++ b/source/routes/motion.ts @@ -19,6 +19,26 @@ import { recordingsLowSpaceThresholdMb } from '../../config.json' import { notify } from './notifications' import { rateLimit } from 'express-rate-limit' +const streamOperationQueues = new Map>() + +function queueStreamOperation( + streamId: string, + operation: () => Promise, +): Promise { + const previous = streamOperationQueues.get(streamId) ?? Promise.resolve() + const current = previous.catch(() => undefined).then(operation) + const settled = current.then( + () => undefined, + () => undefined, + ) + streamOperationQueues.set(streamId, settled) + return current.finally(() => { + if (streamOperationQueues.get(streamId) === settled) { + streamOperationQueues.delete(streamId) + } + }) +} + export default function initializeMotionRoutes( app: Express, streamStates: Record, @@ -201,10 +221,29 @@ export async function saveMotionSegmentsWithRetry( dynamicStreams: Record, streamId: string, retryAttempt: number = 0, + streamState?: StreamMotionState, +): Promise { + return queueStreamOperation(streamId, () => + saveMotionSegmentsWithRetryInternal( + streamStates, + dynamicStreams, + streamId, + retryAttempt, + streamState, + ), + ) +} + +async function saveMotionSegmentsWithRetryInternal( + streamStates: Record, + dynamicStreams: Record, + streamId: string, + retryAttempt: number, + streamState?: StreamMotionState, ): Promise { let state = streamStates[streamId] if (!state) { - await initializeStreamState(streamId) + state = await initializeStreamState(streamId) logMotion( `[${streamId}] Motion save failed due to missing stream state`, 'error', @@ -212,10 +251,20 @@ export async function saveMotionSegmentsWithRetry( return } + if (!streamState) streamState = cloneStateForSave(state) + + // Reset state for new recordings + resetStreamState(state) + const maxRetries = 2 try { - await saveMotionSegments(streamStates, dynamicStreams, streamId) + await saveMotionSegments( + streamState, + streamStates, + dynamicStreams, + streamId, + ) state.saveRetryCount = 0 // Reset retry count on success } catch (error) { logMotion( @@ -234,19 +283,13 @@ export async function saveMotionSegmentsWithRetry( dynamicStreams, streamId, retryAttempt + 1, + streamState, ) }, delay) } else { logMotion( - `[${streamId}] Failed to save motion segments after ${maxRetries + 1} attempts, giving up`, + `[${streamId}] Failed to save motion segments after ${maxRetries + 1} attempts, giving up (you can find temporary video files in the "flush" directory of recordings before the next restart)`, ) - // Reset state on failure - state.savingInProgress = false - state.currentSaveProcess = null - state.saveRetryCount = 0 - state.motionSegments = [] - state.currentRecordingMotionTimestamps = [] - state.segmentTimestampMap.clear() } } } @@ -257,6 +300,22 @@ export async function flushMotionSegmentsWithRetry( dynamicStreams: Record, streamId: string, retryAttempt: number = 0, +): Promise { + return queueStreamOperation(streamId, () => + flushMotionSegmentsWithRetryInternal( + streamStates, + dynamicStreams, + streamId, + retryAttempt, + ), + ) +} + +async function flushMotionSegmentsWithRetryInternal( + streamStates: Record, + dynamicStreams: Record, + streamId: string, + retryAttempt: number, ): Promise { const state = streamStates[streamId] ?? (await initializeStreamState(streamId)) @@ -528,19 +587,11 @@ async function flushMotionSegments( // --- Modified saveMotionSegments --- async function saveMotionSegments( + streamState: StreamMotionState, streamStates: Record, dynamicStreams: Record, streamId: string, -): Promise { - const state = streamStates[streamId] - if (!state) { - await initializeStreamState(streamId) - logMotion( - `[${streamId}] Motion save failed due to missing stream state`, - 'error', - ) - return - } +): Promise { const stream = dynamicStreams[streamId] // If low disk space, purge and skip save @@ -552,50 +603,46 @@ async function saveMotionSegments( if (low) { logMotion(`[${streamId}] Skipping save due to low disk space`) // ensure state cleaned - state.savingInProgress = false - state.currentSaveProcess = null - state.motionSegments = [] - state.flushedSegments = [] - state.flushRecordings = [] - state.currentRecordingMotionTimestamps = [] - state.segmentTimestampMap.clear() - return + resetStreamState(streamState) + return streamState } - if (state.savingInProgress) { + if (streamState.savingInProgress) { logMotion( `[${streamId}] Save operation already in progress, rescheduling`, 'warn', ) setTimeout( - () => saveMotionSegments(streamStates, dynamicStreams, streamId), + () => + saveMotionSegments(streamState, streamStates, dynamicStreams, streamId), 1000, ) - return + return streamState } - if (state.flushingSegments.length > 0) { + if (streamState.flushingSegments.length > 0) { console.warn( `[${streamId}] Flush and save operations called simultaneously, cancelling flush and rescheduling save`, ) - state.cancelFlush = true + streamState.cancelFlush = true setTimeout( - () => saveMotionSegments(streamStates, dynamicStreams, streamId), + () => + saveMotionSegments(streamState, streamStates, dynamicStreams, streamId), 1000, ) - return + return streamState } - state.savingInProgress = true + streamState.savingInProgress = true // Normalize motion timestamps at the end of motion events - state.currentRecordingMotionTimestamps = - state.currentRecordingMotionTimestamps.map( + streamState.currentRecordingMotionTimestamps = + streamState.currentRecordingMotionTimestamps.map( (timestamp) => timestamp + RECENT_SEGMENT_BUFFER * 2 * 500, ) // Push forward due to recent segment buffer (motion processing lag-behind) // Gather flushed recordings - const flushedFiles = [...new Set(state.flushRecordings)] + const flushedFiles = [...new Set(streamState.flushRecordings)] const existingFlushedPromises = flushedFiles .sort((a, b) => { const getNum = (fname: string) => @@ -616,7 +663,7 @@ async function saveMotionSegments( .map((f) => f.filePath) // Gather unflushed segments - const uniqueSegments = [...new Set(state.motionSegments)] + const uniqueSegments = [...new Set(streamState.motionSegments)] const existingSegmentsPromises = uniqueSegments.map(async (segmentPath) => { return { segmentPath, @@ -641,18 +688,14 @@ async function saveMotionSegments( if (concatList.length === 0) { logMotion(`[${streamId}] No segments or flushed files to save`) - state.savingInProgress = false - state.motionSegments = [] - state.flushedSegments = [] - state.currentRecordingMotionTimestamps = [] - state.segmentTimestampMap.clear() - return + resetStreamState(streamState) + return streamState } const listFile = path.join(stream.config.hlsDir, 'concat_list.txt') await fs.promises.writeFile(listFile, concatList.join('\n')) - const outFile = path.join(stream.config.recordDir, state.recordingTitle) + const outFile = path.join(stream.config.recordDir, streamState.recordingTitle) const thumbFile = path.join( stream.config.thumbDir, path.basename(outFile).replace(/\.mp4$/, '.jpg'), @@ -665,31 +708,21 @@ async function saveMotionSegments( } flushed + ${existingSegments.length} segments) to ${path.basename(outFile)}`, ) - return new Promise((resolve) => { + return new Promise((resolve) => { const ffmpegProcess = exec(ffmpegConcatCmd, async (err) => { if (ffmpegProcess.killed) { logMotion(`[${streamId}] Save operation was canceled`) - state.savingInProgress = false - state.currentSaveProcess = null + resetStreamState(streamState) await safeUnlinkWithRetry(listFile) - resolve() + resolve(streamState) return } if (err) { logMotion(`[${streamId}] FFmpeg concat failed: ${err}`, 'error') - state.savingInProgress = false - state.currentSaveProcess = null - state.motionSegments = [] - state.flushedSegments = [] - state.flushRecordings = [] - state.currentRecordingMotionTimestamps = [] - state.nextFlushNumber = 1 - state.notificationSent = false - state.motionStartedAt = 0 - state.segmentTimestampMap.clear() + resetStreamState(streamState) await safeUnlinkWithRetry(listFile) - resolve() + resolve(streamState) return } @@ -700,27 +733,20 @@ async function saveMotionSegments( async () => { // Clean up segments and flushed files const promises = [ - ...state.flushedSegments.concat(existingSegments).map((segment) => { - if (!state.recentSegments.includes(segment)) { - return safeUnlinkWithRetry(segment) - } - return Promise.resolve(void 0) - }), + ...streamState.flushedSegments + .concat(existingSegments) + .map((segment) => { + if (!streamState.recentSegments.includes(segment)) { + return safeUnlinkWithRetry(segment) + } + return Promise.resolve(void 0) + }), ...existingFlushedFiles.map((f) => safeUnlinkWithRetry(f)), safeUnlinkWithRetry(listFile), ] await Promise.all(promises) logMotion(`[${streamId}] Cleared flushDir and saved segments`) - state.motionSegments = [] - state.flushRecordings = [] - state.flushedSegments = [] - state.nextFlushNumber = 1 - state.notificationSent = false - state.motionStartedAt = 0 - state.savingInProgress = false - state.currentSaveProcess = null - state.segmentTimestampMap.clear() // Save to DB try { @@ -735,7 +761,7 @@ async function saveMotionSegments( update: { duration, motionTimestamps: JSON.stringify( - state.currentRecordingMotionTimestamps, + streamState.currentRecordingMotionTimestamps, ), updatedAt: new Date(), }, @@ -744,15 +770,13 @@ async function saveMotionSegments( filename, duration, motionTimestamps: JSON.stringify( - state.currentRecordingMotionTimestamps, + streamState.currentRecordingMotionTimestamps, ), recordedAt, updatedAt: new Date(), }, }) - state.currentRecordingMotionTimestamps = [] - logMotion( `[${streamId}] Successfully saved ${filename} (${duration}s, ${concatList.length} items)`, ) @@ -762,35 +786,34 @@ async function saveMotionSegments( 'error', ) } - resolve() + + resetStreamState(streamState) + resolve(streamState) }, ) thumbProcess.on('exit', () => { - state.currentSaveProcess = null + streamState.currentSaveProcess = null }) - state.currentSaveProcess = thumbProcess + streamState.currentSaveProcess = thumbProcess }) - state.currentSaveProcess = ffmpegProcess - - const saveTimeout = setTimeout(() => { - if (state.currentSaveProcess && !state.currentSaveProcess.killed) { - logMotion(`[${streamId}] Save operation timed out, killing process`) - state.currentSaveProcess.kill('SIGTERM') - state.motionSegments = [] - state.flushRecordings = [] - state.nextFlushNumber = 1 - state.notificationSent = false - state.motionStartedAt = 0 - state.savingInProgress = false - state.currentSaveProcess = null - state.currentRecordingMotionTimestamps = [] - state.segmentTimestampMap.clear() - resolve() - } - }, 60000) + streamState.currentSaveProcess = ffmpegProcess + + const saveTimeout = setTimeout( + () => { + if ( + streamState.currentSaveProcess && + !streamState.currentSaveProcess.killed + ) { + logMotion(`[${streamId}] Save operation timed out, killing process`) + resetStreamState(streamState) + resolve(streamState) + } + }, + 5 * 60 * 1000, + ) // 5 minute timeout ffmpegProcess.on('exit', () => { clearTimeout(saveTimeout) @@ -810,3 +833,34 @@ async function getVideoDuration(filePath: string): Promise { }) }) } + +function resetStreamState(streamState: StreamMotionState) { + streamState.currentSaveProcess?.kill('SIGTERM') + streamState.savingInProgress = false + streamState.currentSaveProcess = null + streamState.motionSegments = [] + streamState.flushedSegments = [] + streamState.flushRecordings = [] + streamState.currentRecordingMotionTimestamps = [] + streamState.nextFlushNumber = 1 + streamState.notificationSent = false + streamState.motionStartedAt = 0 + streamState.segmentTimestampMap.clear() + streamState.currentRecordingMotionTimestamps = [] +} + +function cloneStateForSave(state: StreamMotionState): StreamMotionState { + return { + ...state, + segmentTimestampMap: new Map(state.segmentTimestampMap), + motionSegments: [...state.motionSegments], + flushingSegments: [...state.flushingSegments], + recentSegments: [...state.recentSegments], + flushedSegments: [...state.flushedSegments], + flushRecordings: [...state.flushRecordings], + currentRecordingMotionTimestamps: [ + ...state.currentRecordingMotionTimestamps, + ], + currentSaveProcess: null, + } +} diff --git a/source/routes/recordings.ts b/source/routes/recordings.ts index 28d08b0..e288807 100644 --- a/source/routes/recordings.ts +++ b/source/routes/recordings.ts @@ -160,7 +160,7 @@ export default function initializeRecordingRoutes( const { streamId, page } = req.params const username = req.user!.username const pageNum = Math.max(1, parseInt(page, 10) ?? 1) - const PAGE_SIZE = 50 + const PAGE_SIZE = 20 const total = await prisma.motionRecording.count({ where: { streamId } }) const recordings = await prisma.motionRecording.findMany({ @@ -171,6 +171,7 @@ export default function initializeRecordingRoutes( filename: true, motionTimestamps: true, duration: true, + nickname: true, }, skip: (pageNum - 1) * PAGE_SIZE, take: PAGE_SIZE, diff --git a/source/routes/signed.ts b/source/routes/signed.ts index e142395..ad39940 100644 --- a/source/routes/signed.ts +++ b/source/routes/signed.ts @@ -349,8 +349,8 @@ export default function initializeSignedRoutes( const streamSignedUrlLimiter = rateLimit({ validate: { ip: false }, - windowMs: 30 * 1000, // 30 seconds - max: 8, + windowMs: 2 * 1000, // 2 seconds + max: 10, standardHeaders: true, legacyHeaders: false, }) @@ -373,7 +373,7 @@ export default function initializeSignedRoutes( const videoAndThumbnailSignedUrlLimiter = rateLimit({ validate: { ip: false }, - windowMs: 2 * 1000, // 2 seconds + windowMs: 500, // Half second max: 50, standardHeaders: true, legacyHeaders: false, @@ -406,7 +406,7 @@ export default function initializeSignedRoutes( const videosAndThumbnailsSignedUrlLimiter = rateLimit({ validate: { ip: false }, windowMs: 2 * 1000, // 2 seconds - max: 2, + max: 5, standardHeaders: true, legacyHeaders: false, }) @@ -686,7 +686,7 @@ function verifySignedUrl( } // --- Helper to create a signed stream playlist URL for a specific stream --- -function createSignedStreamUrl(streamId: string, expiresInSeconds = 300) { +function createSignedStreamUrl(streamId: string, expiresInSeconds = 60) { const expires = Math.floor(Date.now() / 1000) + expiresInSeconds const secret = process.env.SIGNED_URL_SECRET ?? JWT_SECRET const data = `stream:${streamId}:${expires}` diff --git a/source/routes/streams.ts b/source/routes/streams.ts index cbdb39c..7d74104 100644 --- a/source/routes/streams.ts +++ b/source/routes/streams.ts @@ -75,9 +75,9 @@ export default function initializeStreamRoutes( return } if (!nickname || !ffmpegInput) { - res - .status(400) - .json({ error: 'Nickname and ffmpegInput are required.' }) + res.status(400).json({ + error: `Nickname and camera ${rtspUser || rtspPass ? 'IP' : 'information'} are required.`, + }) return } const validation = validateStreamInput({ @@ -265,7 +265,11 @@ export default function initializeStreamRoutes( `[StreamManager] Failed to destroy stream ${id}:`, err, ) - res.status(500).json({ error: 'Failed to destroy stream.' }) + res + .status(500) + .json({ + error: 'Failed to shut down the camera monitor process.', + }) return } stopStreamMotionMonitoring(id) @@ -305,7 +309,8 @@ function validateStreamInput({ if (isRtsp && ((rtspUser && !rtspPass) || (!rtspUser && rtspPass))) { return { valid: false, - error: 'Both rtspUser and rtspPass must be provided for RTSP streams.', + error: + 'Either neither or both of rtspUser and rtspPass must be provided for RTSP streams.', } } return { valid: true } diff --git a/source/streamManager.ts b/source/streamManager.ts index bf5eec2..b8223ff 100644 --- a/source/streamManager.ts +++ b/source/streamManager.ts @@ -3,6 +3,7 @@ import fs from 'fs' import { exec, spawn } from 'child_process' import { StreamMotionState } from './types/stream' import { logMotion } from './logMotion' +import { isNvidiaAvailable } from './hardwareCheck' import { saveMotionSegments, setupStreamMotionMonitoring, @@ -350,7 +351,7 @@ export class StreamManager { const inputUrl = inputIsRtsp ? this.getRtspUrlWithAuth() : this.config.ffmpegInput - let ffmpegArgs: string[] + let ffmpegArgs: (string | string[])[] if (inputIsRtsp) { ffmpegArgs = [ '-y', @@ -367,11 +368,50 @@ export class StreamManager { '-probesize', '2000000', + // TODO: Add to stream settings + '-noautorotate', + '-i', inputUrl, - '-c:v', - 'copy', + // TODO: Add drop-down options to stream settings + (() => { + if (isNvidiaAvailable()) { + return [ + '-c:v', + 'h264_nvenc', + + '-preset', + 'p1', // Lowest latency, highest speed profile for NVENC + + '-tune', + 'ull', // Ultra-low latency tuning + + '-g', + '30', // Strictly force a keyframe every 30 frames + + '-sc_threshold', // Disable scene-change detection to enforce strict GOP structure + '0', + ] + } else { + return [ + '-c:v', + 'libx264', + + '-preset', + 'veryfast', + + '-tune', + 'zerolatency', + + '-g', + '30', + + '-sc_threshold', + '0', + ] + } + })(), '-map', '0:v:0', @@ -492,7 +532,7 @@ export class StreamManager { `[${this.config.id}] Starting FFmpeg with args:`, ffmpegArgs.join(' '), ) - const ffmpegProcess = spawn('ffmpeg', ffmpegArgs, { + const ffmpegProcess = spawn('ffmpeg', ffmpegArgs.flat(), { stdio: ['ignore', 'ignore', 'pipe'], shell: false, }) diff --git a/tsconfig.json b/tsconfig.json index b41532d..bda0125 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,8 @@ "source/**/*.cjs", "source/**/*.mjsx", "source/**/*.cjsx", + "web2/**/*.ts", + "web2/**/*.tsx", "types/**/*.d.ts", "scripts/**/*.ts", "eslint.config.mts" diff --git a/web/index.html b/web/index.html index 9d4f2de..6da4bfc 100644 --- a/web/index.html +++ b/web/index.html @@ -1,22 +1,24 @@ + + + + + + + + + Gander + - - - - - - - - - gander - - - - -
- - - + + +
+ + diff --git a/web/package.json b/web/package.json index bc0d4b5..d8f55cf 100644 --- a/web/package.json +++ b/web/package.json @@ -22,17 +22,17 @@ "eslint-plugin-import": "2.32.0", "eslint-plugin-unused-imports": "4.4.1", "hls.js": "1.6.16", - "react": "19.2.8", - "react-dom": "19.2.8", - "react-icons": "5.6.0", + "react": "19.3.0", + "react-dom": "19.3.0", + "react-icons": "5.7.0", "react-router-dom": "7.15.0", "react-window": "1.8.11", "socket.io-client": "4.8.3" }, "devDependencies": { "@eslint/js": "10.0.1", - "@types/react": "19.2.17", - "@types/react-dom": "19.2.3", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", "@types/react-window": "1.8.8", "@vitejs/plugin-react": "6.0.2", "eslint": "10.3.0", diff --git a/web/src/StreamPage.tsx b/web/src/StreamPage.tsx index 4489c74..d495ca0 100644 --- a/web/src/StreamPage.tsx +++ b/web/src/StreamPage.tsx @@ -146,7 +146,7 @@ export default function StreamPage({ const [mobileSearchSticky, setMobileSearchSticky] = useState(false) const lastStickyRef = useRef(false) const lastMobileStickyRef = useRef(false) - const PAGE_SIZE = 50 + const PAGE_SIZE = 20 const [currentPage, setCurrentPage] = useState(1) const [lastSeenRecording, setLastSeenRecording] = useLocalStorageState<{ [streamId: string]: string | null diff --git a/web/src/hooks/useSignedUrl.ts b/web/src/hooks/useSignedUrl.ts index cc6699a..242be17 100644 --- a/web/src/hooks/useSignedUrl.ts +++ b/web/src/hooks/useSignedUrl.ts @@ -16,20 +16,40 @@ interface SignedUrlCache { urls: Record } +let signedUrlCacheExpirationsSorted: Array<{ + key: SignedUrlKey + expiresAt: number +}> = [] + // --- in-memory cache --- let memoryCache: SignedUrlCache | null = null +let cacheCleanInterval: number -function loadCache(): SignedUrlCache { +function getCache(): SignedUrlCache { + if (!cacheCleanInterval) { + cacheCleanInterval = setInterval(cleanupExpiredCacheEntries, 60 * 1000) + } if (memoryCache) return memoryCache + try { - const cached = JSON.parse(localStorage.getItem('signedUrlCache') || 'null') + const cached: SignedUrlCache = JSON.parse( + localStorage.getItem('signedUrlCache') || 'null', + ) + if (cached && cached.urls) { memoryCache = cached + signedUrlCacheExpirationsSorted = Object.entries(cached.urls) + .map(([key, entry]) => ({ + key: key as SignedUrlKey, + expiresAt: entry.expiresAt, + })) + .sort((a, b) => a.expiresAt - b.expiresAt) return memoryCache || { urls: {} } } } catch { // Ignore parse errors } + memoryCache = { urls: {} } return memoryCache } @@ -53,6 +73,26 @@ function getCacheKey( return `${streamId}_${type}_${filename}` } +function cleanupExpiredCacheEntries() { + const cache = getCache() + const now = Math.floor(Date.now() / 1000) + let removed = false + + for (const entry of signedUrlCacheExpirationsSorted) { + if (entry.expiresAt > now + 10) break + if (cache.urls[entry.key]) { + delete cache.urls[entry.key] + removed = true + } + } + + signedUrlCacheExpirationsSorted = signedUrlCacheExpirationsSorted.filter( + (entry) => entry.expiresAt > now + 10 && cache.urls[entry.key], + ) + + if (removed) saveCache() +} + const queuedForFetching: { [streamId: string]: { video: boolean @@ -85,7 +125,7 @@ export function useSignedUrl( useEffect(() => { let isMounted = true - const cache = loadCache() + const cache = getCache() const cacheKey = getCacheKey(filename, type, streamId) const cached = cache.urls[cacheKey] const now = Math.floor(Date.now() / 1000) @@ -108,7 +148,7 @@ export function useSignedUrl( return } - const cache = loadCache() + const cache = getCache() const cacheKey = getCacheKey(filename, type, streamId) const cached = cache.urls[cacheKey] const now = Math.floor(Date.now() / 1000) @@ -120,15 +160,15 @@ export function useSignedUrl( } if (!signedUrlsToFetch[streamId]) { -signedUrlsToFetch[streamId] = { + signedUrlsToFetch[streamId] = { thumbnail: [], video: [], } -} + } if (isMounted) { -signedUrlsToFetch[streamId][type].push(encodeURIComponent(filename)) -} + signedUrlsToFetch[streamId][type].push(encodeURIComponent(filename)) + } if (isMounted && !queuedForFetching[streamId]) { queuedForFetching[streamId] = { @@ -210,7 +250,7 @@ export function clearSignedUrlCache() { // Export function to get cache statistics export function getSignedUrlCacheStats() { - const cache = loadCache() + const cache = getCache() const now = Math.floor(Date.now() / 1000) const expiredUrls = Object.values(cache.urls).filter( @@ -226,7 +266,7 @@ export function getSignedUrlCacheStats() { // Export function to clear cache for specific stream (useful when stream is deleted) export function clearStreamCache(streamId: string) { - const cache = loadCache() + const cache = getCache() const keysToRemove = Object.keys(cache.urls).filter((key) => key.startsWith(`${streamId}_`), ) as Array<`${string}_video_${string}` | `${string}_thumbnail_${string}`> diff --git a/web/tsconfig.json b/web/tsconfig.json index 1ffef60..a112dfb 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,5 +1,10 @@ { - "files": [], + "files": [ + "../eslint.config.mts", + "../prisma.config.ts", + "../capacitor.config.ts" + ], + "include": ["../web2/src", "../source"], "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" } diff --git a/web2/.gitignore b/web2/.gitignore new file mode 100644 index 0000000..f2fa5d2 --- /dev/null +++ b/web2/.gitignore @@ -0,0 +1,134 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +.DS_Store + +llms* diff --git a/web2/README.md b/web2/README.md new file mode 100644 index 0000000..0d43a1d --- /dev/null +++ b/web2/README.md @@ -0,0 +1,5 @@ +# Mantine Vite template + +Get started with the template by clicking `Use this template` button on the top of the page. + +[Documentation](https://mantine.dev/guides/vite/) diff --git a/web2/index.html b/web2/index.html new file mode 100644 index 0000000..58fef35 --- /dev/null +++ b/web2/index.html @@ -0,0 +1,24 @@ + + + + + + + Gander + + + + + +
+ + + diff --git a/web2/package.json b/web2/package.json new file mode 100644 index 0000000..0da8eed --- /dev/null +++ b/web2/package.json @@ -0,0 +1,41 @@ +{ + "name": "web2", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@gfazioli/mantine-video": "1.2.2", + "@mantine/carousel": "9.6.1", + "@mantine/core": "9.6.1", + "@mantine/dates": "9.6.1", + "@mantine/form": "9.6.1", + "@mantine/hooks": "9.6.1", + "@mantine/lightbox": "9.6.1", + "@mantine/modals": "9.6.1", + "@mantine/notifications": "9.6.1", + "@mantine/nprogress": "9.6.1", + "@tabler/icons-react": "3.46.0", + "dayjs": "1.11.23", + "embla-carousel": "8.6.0", + "embla-carousel-react": "8.6.0", + "hls.js": "1.7.1", + "react": "19.3.0", + "react-dom": "19.3.0", + "react-icons": "5.7.0" + }, + "devDependencies": { + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "postcss": "8.5.26", + "postcss-preset-mantine": "1.18.0", + "postcss-simple-vars": "7.0.1", + "typescript": "~6.0.3", + "vite": "8.0.16" + } +} diff --git a/web2/postcss.config.cjs b/web2/postcss.config.cjs new file mode 100644 index 0000000..e817f56 --- /dev/null +++ b/web2/postcss.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + plugins: { + "postcss-preset-mantine": {}, + "postcss-simple-vars": { + variables: { + "mantine-breakpoint-xs": "36em", + "mantine-breakpoint-sm": "48em", + "mantine-breakpoint-md": "62em", + "mantine-breakpoint-lg": "75em", + "mantine-breakpoint-xl": "88em", + }, + }, + }, +}; diff --git a/web2/public/ic_launcher_round.png b/web2/public/ic_launcher_round.png new file mode 100644 index 0000000..c82961a Binary files /dev/null and b/web2/public/ic_launcher_round.png differ diff --git a/web2/src/App.tsx b/web2/src/App.tsx new file mode 100644 index 0000000..6febd80 --- /dev/null +++ b/web2/src/App.tsx @@ -0,0 +1,591 @@ +import './styles.css' +import '@mantine/core/styles.css' +import '@mantine/lightbox/styles.css' +import '@mantine/notifications/styles.css' +import '@gfazioli/mantine-video/styles.css' +import { useState, useEffect } from 'react' +import { Notifications } from '@mantine/notifications' +import { ModalsProvider } from '@mantine/modals' +import { useLocalStorage } from '@mantine/hooks' +import { LoadingOverlay, MantineProvider } from '@mantine/core' +import theme from './theme' +import SecureStorage from './secure-storage' +import { API_BASE, setAuthHandlers, authFetch } from './main' +import { + getDeviceFingerprint, + getSessionId, + Session, + TrustedDevice, +} from './device-info' +import LoginLayout from './Layout/LoginLayout' +import FullLayout from './Layout/FullLayout' + +export type RecordingType = { + streamId: string + filename: string + duration: number + motionTimestamps: number[] +} + +export function debugLog( + message: string, + level: 'log' | 'warn' | 'error' = 'log', +) { + if (level === 'log') console.log(message) + else if (level === 'warn') console.warn(message) + else if (level === 'error') console.error(message) +} + +export default function App() { + const [authenticated, setAuthenticated] = useState(null) + const [_signedUrlCache, setSignedUrlCache] = useLocalStorage<{ + [cacheKey: string]: { + url: string + expiresAt: number + streamId: string + filename: string + type: 'video' | 'thumbnail' + lastAccessed: number + } + }>({ key: 'signedUrlCache', defaultValue: {} }) + const [_signedLiveThumbUrls, setSignedLiveThumbUrls] = useLocalStorage<{ + [streamId: string]: { url: string; expires: number } + }>({ key: 'signedLiveThumbUrls', defaultValue: {} }) + const [_cachedRecordings, setCachedRecordings] = useLocalStorage<{ + [streamId: string]: RecordingType[] + }>({ key: 'cachedRecordings', defaultValue: {} }) + const [_totalRecordings, setTotalRecordings] = useLocalStorage<{ + [streamId: string]: number + }>({ key: 'totalRecordings', defaultValue: {} }) + const [_cachedRecordingRanges, setCachedRecordingRanges] = useLocalStorage<{ + [streamId: string]: Array<{ from: string; to: string }> + }>({ key: 'cachedRecordingRanges', defaultValue: {} }) + const [_cachedPages, setCachedPages] = useLocalStorage<{ + [streamId: string]: number[] + }>({ key: 'cachedPages', defaultValue: {} }) + const [knownSessions, setKnownSessions] = useLocalStorage({ + key: 'knownSessionIds', + defaultValue: [], + }) + const [showSessionMonitor, setShowSessionMonitor] = useState(false) + const [hasCheckedSessions, setHasCheckedSessions] = useState(false) + const [sessions, setSessions] = useState<(Session & TrustedDevice)[]>([]) + const [lastFailedJwt, setFailedJwt] = useState() + // Helper: Try to refresh token + const tryRefreshToken = async (): Promise => { + debugLog('=== STARTING TOKEN REFRESH ===') + + try { + debugLog('Checking for API key in localStorage') + if (localStorage.getItem('ak')) { + debugLog('API key found in localStorage, skipping token refresh') + setAuthenticated(true) + return true + } + } catch { + debugLog( + 'Error occurred while checking for API key in localStorage', + 'error', + ) + } + + const refreshToken = await SecureStorage.getRefreshToken().catch( + console.error, + ) + + try { + debugLog('Attempting to get refresh token from storage') + + const rtExists = + !!refreshToken || !!(await cookieStore?.get('_rtexists'))?.value + if (!rtExists) { + debugLog( + `No refresh token available for refresh attempt, _rtexists: '${rtExists}'`, + 'warn', + ) + setAuthenticated(false) + return false + } + + debugLog('Refresh token found, preparing refresh request') + + // Check if another tab is already refreshing + const refreshInProgress = localStorage.getItem('tokenRefreshInProgress') + if (refreshInProgress) { + const startTime = parseInt(refreshInProgress) + const timeSinceStart = Date.now() - startTime + + if (timeSinceStart < 5000) { + debugLog( + `Another tab is refreshing (${timeSinceStart}ms ago), waiting...`, + ) + + return new Promise((resolve) => { + let attempts = 0 + const maxAttempts = 50 // 5 seconds max wait + const tokenChannel = new BroadcastChannel('tokenUpdates') + + const handleTokenUpdate = (event: MessageEvent) => { + if (event.data.type === 'TOKEN_UPDATED') { + debugLog( + 'Another tab successfully refreshed token via broadcast', + ) + setAuthenticated(true) + tokenChannel.close() + resolve(true) + } + } + + tokenChannel.addEventListener('message', handleTokenUpdate) + + const checkComplete = () => { + attempts++ + const stillInProgress = localStorage.getItem( + 'tokenRefreshInProgress', + ) + const newToken = localStorage.getItem('jwt') + + if (!stillInProgress && newToken) { + debugLog( + 'Another tab successfully refreshed token via localStorage check', + ) + setAuthenticated(true) + tokenChannel.close() + resolve(true) + } else if (!stillInProgress || attempts >= maxAttempts) { + debugLog( + 'Token refresh failed or timed out in other tab', + 'error', + ) + setAuthenticated(false) + tokenChannel.close() + resolve(false) + } else { + setTimeout(checkComplete, 100) + } + } + setTimeout(checkComplete, 100) + }) + } else { + debugLog( + `Stale refresh in progress flag detected (${timeSinceStart}ms old), clearing and proceeding`, + ) + localStorage.removeItem('tokenRefreshInProgress') + } + } + + // Mark refresh as in progress + localStorage.setItem('tokenRefreshInProgress', Date.now().toString()) + + const deviceInfo = await getDeviceFingerprint() + const data = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + deviceInfo, + ...(refreshToken ? { 'refresh-token': refreshToken } : {}), + }), + } + + const refreshStartTime = Date.now() + // The current refresh token will be sent in the http only cookie + const res = await Promise.race([ + fetch(`${API_BASE}/api/refresh-token`, data), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Token refresh request timeout')), + 15000, + ), + ), + ]) + + const requestDuration = Date.now() - refreshStartTime + debugLog( + `Refresh token response received after ${requestDuration}ms, status: ${res.status}`, + ) + + if (res.status === 200) { + const data = await res.json() + if (data && data.token && data.refreshToken) { + debugLog('Token refresh successful, storing new tokens') + + localStorage.setItem('jwt', data.token) + + localStorage.removeItem('tokenRefreshInProgress') + await SecureStorage.setRefreshToken(data.refreshToken) + + // Broadcast to all tabs + const tokenChannel = new BroadcastChannel('tokenUpdates') + tokenChannel.postMessage({ + type: 'TOKEN_UPDATED', + token: data.token, + timestamp: Date.now(), + }) + tokenChannel.close() + + setAuthenticated(true) + debugLog('=== TOKEN REFRESH SUCCESSFUL ===') + return true + } else { + debugLog('Token refresh response missing required fields', 'error') + } + } else { + debugLog(`Token refresh failed with status: ${res.status}`, 'error') + try { + const errorText = await res.text() + debugLog(`Error response body: ${errorText}`, 'error') + } catch (_) { + debugLog('Could not read error response body', 'warn') + } + } + } catch (error) { + debugLog(`Token refresh error: ${error}`, 'error') + } + + // Cleanup on failure + debugLog('Token refresh failed, cleaning up', 'error') + await SecureStorage.removeRefreshToken() + localStorage.removeItem('jwt') + localStorage.removeItem('tokenRefreshInProgress') + + setAuthenticated(false) + debugLog('=== TOKEN REFRESH FAILED ===') + return false + } + + // Enhanced logout function - PREVENT INFINITE LOOP + const logout = async (skipBroadcast = false): Promise => { + try { + localStorage.removeItem('ak') + } catch { + /* ignore */ + } + try { + const refreshToken = await SecureStorage.getRefreshToken().catch( + console.error, + ) + const rtExists = + !!refreshToken || !!(await cookieStore?.get('_rtexists'))?.value + if (!rtExists) { + debugLog( + `No refresh token available for logout attempt, _rtexists: '${rtExists}'`, + 'warn', + ) + setAuthenticated(false) + return + } + + const fingerprint = await getDeviceFingerprint() + + // Notify server of logout - use fetch directly to avoid recursive authFetch calls + await fetch(`${API_BASE}/api/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + clientId: fingerprint.clientId, + ...(refreshToken ? { Cookie: `_rt=${refreshToken}` } : {}), + }), + }).catch(console.error) + } catch (error) { + console.error('Error during logout:', error) + } finally { + // Clear all tokens and authentication state + await SecureStorage.clearAll() + localStorage.removeItem('jwt') + setAuthenticated(false) + + // Only broadcast if not called from a broadcast event (prevent infinite loop) + if (!skipBroadcast) { + console.log('Broadcasting logout to other tabs') + const tokenChannel = new BroadcastChannel('tokenUpdates') + tokenChannel.postMessage({ + type: 'LOGOUT', + timestamp: Date.now(), + }) + tokenChannel.close() + } else { + console.log('Logout called from broadcast, skipping broadcast') + } + } + } + + // 1. FIRST: Set up BroadcastChannel listener (before any token operations) + useEffect(() => { + const tokenChannel = new BroadcastChannel('tokenUpdates') + + const handleTokenUpdate = (event: MessageEvent) => { + console.log('Token update received via broadcast: ' + event.data.type) + + if (event.data.type === 'TOKEN_UPDATED') { + const newToken = event.data.token + if (newToken) { + // Update localStorage if it's different (in case this tab missed it) + const currentToken = localStorage.getItem('jwt') + if (currentToken !== newToken) { + localStorage.setItem('jwt', newToken) + } + + console.log('JWT updated via broadcast, staying authenticated') + setAuthenticated(true) + + // Clear any pending refresh operations in this tab + localStorage.removeItem('tokenRefreshInProgress') + } + } else if (event.data.type === 'LOGOUT') { + console.log('Logout broadcast received - logging out this tab') + // Call logout with skipBroadcast=true to prevent infinite loop + logout(true) + } + } + + tokenChannel.addEventListener('message', handleTokenUpdate) + console.log('BroadcastChannel listener set up') + + return () => { + tokenChannel.removeEventListener('message', handleTokenUpdate) + tokenChannel.close() + } + }, []) // Set up once on mount + + // 2. SECOND: Set up global auth handlers + useEffect(() => { + const logoutWrapper = () => logout(false) // Always broadcast from explicit logouts + setAuthHandlers(logoutWrapper, tryRefreshToken) + console.log('Global auth handlers set up') + }, []) + + // 3. THIRD: Handle initial authentication (after listeners are ready) + useEffect(() => { + const initAuth = async () => { + debugLog('=== STARTING INITIAL AUTH CHECK ===') + + // Add a small delay to ensure BroadcastChannel is fully set up + await new Promise((resolve) => setTimeout(resolve, 50)) + + const token = localStorage.getItem('jwt') + + if (token) { + debugLog('Found existing JWT, assuming authenticated') + setAuthenticated(true) + } else { + debugLog('No JWT found, attempting token refresh') + await tryRefreshToken() + } + + debugLog('=== INITIAL AUTH CHECK COMPLETE ===') + } + + initAuth().catch((error) => { + debugLog(`Critical error in initAuth: ${error}`, 'error') + setAuthenticated(false) + }) + }, []) // Run once on mount + + // Enhanced session checking function + const checkForNewSessions = async () => { + if (hasCheckedSessions || !authenticated) return + + try { + console.log('Checking for new sessions...') + const response = await authFetch(`${API_BASE}/api/user/sessions`) + if (!response.ok) return + + const trustedDevices: TrustedDevice[] = await response.json() + console.log(`Fetched ${trustedDevices.length} trusted devices`) + + // Create sessions list + const sessionsList: (Session & TrustedDevice)[] = trustedDevices.map( + (device) => { + // Use the proper getSessionId function from SessionMonitor + const sessionId = getSessionId(device.ip, device.deviceInfo) + return { + ip: device.ip, + firstSeen: device.firstSeen, + lastSeen: device.lastSeen, + isNew: !knownSessions.includes(sessionId), + geolocated: false, // Mark as not geolocated so SessionMonitor can handle it + isGeolocating: false, + location: undefined, + deviceInfo: device.deviceInfo, + loginCount: device.loginCount, + } + }, + ) + + // Sort sessions with new ones first + const sortedSessions = sessionsList.sort((a, b) => + a.isNew === b.isNew ? 0 : a.isNew ? -1 : 1, + ) + setSessions(sortedSessions) + + // Check if there are new sessions + const newSessionsDetected = sortedSessions.some((s) => s.isNew) + + if (newSessionsDetected) { + console.log('New sessions detected, auto-showing session monitor') + setShowSessionMonitor(true) + } + + setHasCheckedSessions(true) + } catch (error) { + console.error('Error checking for new sessions:', error) + setHasCheckedSessions(true) + } + } + + function checkLocalStorageStateConsistency() { + try { + const currentVersion = import.meta.env.VITE_REACT_APP_VERSION ?? '1.0.0' + let lastUpdate = localStorage.getItem('lastUsedAppVersion') + + // If new version, reset cached data + if (lastUpdate && lastUpdate !== currentVersion) { + // Reset all cached states if the website was updated + setCachedRecordings({}) + setCachedRecordingRanges({}) + setCachedPages({}) + setTotalRecordings({}) + setSignedUrlCache({}) + setSignedLiveThumbUrls({}) + localStorage.setItem('lastUsedAppVersion', currentVersion) + alert('Cache reset due to website update or new deployment.') + } + } catch (err) { + alert('Error checking localStorageState consistency!') + console.error('LocalStorage consistency check failed:', err) + } + } + + useEffect(() => { + checkLocalStorageStateConsistency() + }, []) + + // Check for new sessions when authenticated + useEffect(() => { + if (authenticated === true) { + // Delay session check to allow the app to fully load + setTimeout(checkForNewSessions, 2000) + } + }, [authenticated, hasCheckedSessions, knownSessions]) + + // Add periodic session checking (every 5 minutes) + useEffect(() => { + if (!authenticated) return + + const interval = setInterval( + () => { + // Reset hasCheckedSessions to allow periodic checks + setHasCheckedSessions(false) + }, + 5 * 60 * 1000, + ) // 5 minutes + + return () => clearInterval(interval) + }, [authenticated]) + + // --- Refresh JWT token every minute --- + useEffect(() => { + if (!authenticated) return + const interval = setInterval(tryRefreshToken, 60000) + return () => clearInterval(interval) + }, [authenticated]) + + // Add beforeunload event to clear sensitive data (optional) + useEffect(() => { + const handleBeforeUnload = () => { + // Only clear JWT from memory, keep refresh token for next session + // This is optional - you might want to keep the JWT for better UX + const navigationEntries = performance.getEntriesByType( + 'navigation', + ) as PerformanceNavigationTiming[] + if ( + navigationEntries.length > 0 && + navigationEntries[0].type === 'reload' + ) { + // Only on refresh, not on navigation + // localStorage.removeItem('jwt') + } + } + + window.addEventListener('beforeunload', handleBeforeUnload) + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, []) + + useEffect(() => { + if (!authenticated) { + setAuthenticated( + localStorage.getItem('ak') !== null ? true : authenticated, + ) + } + }, []) + + const handleLogin = async (token: string, refreshToken: string) => { + // Show user-friendly error message + if ( + !window.isSecureContext && + location.protocol !== 'https:' && + location.hostname !== 'localhost' + ) { + alert( + 'Note: For enhanced security on remote devices, consider using HTTPS. Authentication will work but tokens will use basic encoding.', + ) + } + + try { + if (!window.isSecureContext) { + await SecureStorage.setRefreshToken(refreshToken) + } + localStorage.setItem('jwt', token) + setAuthenticated(true) + } catch (error) { + console.error('Error storing tokens:', error) + + // Still try to authenticate even if secure storage fails + localStorage.setItem('jwt', token) + // Store refresh token as base64 fallback + // localStorage.setItem('_rt', btoa(refreshToken)) + setAuthenticated(true) + } + } + + // If authenticated is null, we'll show a loading overlay further down + // If it's false, show a sign-in + if (authenticated === false) { + return ( + + + + + + + ) + } + + return ( + + {authenticated ? ( + + + + ) : ( + + )} + + + ) +} diff --git a/web2/src/Component/CreateStream/CreateEditStreamPopover.tsx b/web2/src/Component/CreateStream/CreateEditStreamPopover.tsx new file mode 100644 index 0000000..0c9f4eb --- /dev/null +++ b/web2/src/Component/CreateStream/CreateEditStreamPopover.tsx @@ -0,0 +1,401 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { notifications } from '@mantine/notifications' +import { modals } from '@mantine/modals' +import { useDisclosure, useViewportSize } from '@mantine/hooks' +import { Button, Group, Popover, Stack, Text, TextInput } from '@mantine/core' +import { Stream } from '../../types' +import { API_BASE, authFetch } from '../../main' +import StreamTypeControl from './StreamTypeControl' + +export default function CreateEditStreamPopover(props: { + currentStream?: Stream + streams: Map + setOpen?: (open: boolean) => void + setLayouts: ( + val: + | { + id: number + splitterStreams: string[] + }[] + | (( + prevState: { + id: number + splitterStreams: string[] + }[], + ) => { + id: number + splitterStreams: string[] + }[]), + ) => void + setStream(streamId: string): Promise | void + children: React.ReactNode +}) { + const { width } = useViewportSize() + const [loading, setLoading] = useState(false) + const [open, { set: setOpen, toggle: toggleOpen }] = useDisclosure(false) + + const [nickname, setNickname] = useState('') + const [type, setType] = useState<'RTSP' | 'Local'>('RTSP') + + const [localVideoDevice, setLocalVideo] = useState('') + const [localAudioDevice, setLocalAudio] = useState('') + + const [rtspUrl, setRTSPUrl] = useState('') + const [rtspUsername, setRTSPUsername] = useState('') + const [rtspPassword, setRTSPPassword] = useState('') + + const openDeleteModal = () => + new Promise((resolve) => + modals.openConfirmModal({ + title: 'Delete recording', + children: ( + + Delete {props.currentStream!.nickname}? This will also remove web + access to recordings. + + ), + labels: { confirm: 'Delete', cancel: 'Cancel' }, + onCancel: () => { + notifications.show({ + title: 'Cancelled', + message: 'Camera deletion cancelled.', + color: 'gray', + }) + resolve(false) + }, + onConfirm: () => resolve(true), + zIndex: 10002, + styles: { + root: { + zIndex: 10001, + }, + }, + }), + ) + + const resetInputs = useCallback(() => { + if (props.currentStream) { + if (props.currentStream.ffmpegInput.startsWith('rtsp://')) { + setType('RTSP') + setRTSPUrl(props.currentStream.ffmpegInput.slice(7)) + setRTSPUsername(props.currentStream.rtspUser ?? '') + setRTSPPassword(props.currentStream.rtspPass ?? '') + } else { + setType('Local') + const audioIndex = + props.currentStream.ffmpegInput.lastIndexOf(':audio=') + setLocalVideo(props.currentStream.ffmpegInput.slice(6, audioIndex)) + setLocalAudio(props.currentStream.ffmpegInput.slice(audioIndex + 8)) + } + + setNickname(props.currentStream.nickname) + } else { + setType('RTSP') + setNickname('') + setRTSPUrl('') + setRTSPUsername('') + setRTSPPassword('') + setLocalVideo('') + setLocalAudio('') + } + }, [props.currentStream]) + + useEffect(resetInputs, [props.currentStream]) + + useEffect(() => props.setOpen?.(open), [open]) + + return ( + 768 ? '50dvw' : '90dvw'} + opened={open} + onDismiss={() => setOpen(false)} + shadow="md" + withArrow + closeOnClickOutside={false} + hideDetached={false} + trapFocus={true} + returnFocus={true} + withOverlay + overlayProps={{ + zIndex: 10000, + blur: '8px', + }} + zIndex={10001} + > + + {React.Children.map(props.children, (child) => { + // Ensure it's a valid React element before cloning + if (React.isValidElement(child)) { + return React.cloneElement(child, { + // @ts-ignore + onClick: (event) => { + event.preventDefault() + event.stopPropagation() + toggleOpen() + }, + }) + } + return child + })} + + + + + setNickname(event.currentTarget.value)} + /> + {type === 'Local' ? ( + <> + setLocalVideo(event.currentTarget.value)} + /> + setLocalAudio(event.currentTarget.value)} + /> + + ) : ( + <> + setRTSPUrl(event.currentTarget.value)} + /> + setRTSPUsername(event.currentTarget.value)} + /> + setRTSPPassword(event.currentTarget.value)} + /> + + )} + + <> + {props.currentStream && ( + + )} + {props.currentStream && ( + + )} + + + + + + + + ) +} diff --git a/web2/src/Component/CreateStream/StreamTypeControl.module.css b/web2/src/Component/CreateStream/StreamTypeControl.module.css new file mode 100644 index 0000000..5af4365 --- /dev/null +++ b/web2/src/Component/CreateStream/StreamTypeControl.module.css @@ -0,0 +1,32 @@ +.root { + background-color: light-dark( + var(--mantine-color-white), + var(--mantine-color-dark-6) + ); + box-shadow: var(--mantine-shadow-md); + border: 1px solid + light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-4)); +} + +.indicator { + background-image: linear-gradient( + to right, + var(--mantine-color-blue-filled), + var(--mantine-color-teal-filled) + ); +} + +.control { + &::before { + display: none; + } +} + +.label { + &, + &:hover { + &[data-active] { + color: var(--mantine-color-white); + } + } +} diff --git a/web2/src/Component/CreateStream/StreamTypeControl.tsx b/web2/src/Component/CreateStream/StreamTypeControl.tsx new file mode 100644 index 0000000..28c5178 --- /dev/null +++ b/web2/src/Component/CreateStream/StreamTypeControl.tsx @@ -0,0 +1,18 @@ +import { SegmentedControl } from '@mantine/core' +import classes from './StreamTypeControl.module.css' + +export default function StreamTypeControl(props: { + setType: React.Dispatch> + default?: 'RTSP' | 'Local' +}) { + return ( + + ) +} diff --git a/web2/src/Component/Lightbox/CollapsedLightbox.context.ts b/web2/src/Component/Lightbox/CollapsedLightbox.context.ts new file mode 100644 index 0000000..648fd03 --- /dev/null +++ b/web2/src/Component/Lightbox/CollapsedLightbox.context.ts @@ -0,0 +1,55 @@ +import type { EmblaCarouselType } from 'embla-carousel' +import { type LightboxLabels } from '@mantine/lightbox' +import { + createSafeContext, + type Factory, + type GetStylesApi, +} from '@mantine/core' +import { + LightboxCssVariables, + LightboxRootProps, + LightboxRootStylesNames, +} from './LightboxRoot' +import { CollapsedLightboxSlideData } from './CollapsedLightbox' + +export interface CollapsedLightboxContextValue { + getStyles: GetStylesApi< + Factory<{ + props: LightboxRootProps + ref: HTMLDivElement + stylesNames: LightboxRootStylesNames + vars: LightboxCssVariables + }> + > + labels: LightboxLabels + opened: boolean + slides: CollapsedLightboxSlideData[] + currentIndex: number + currentTitle: React.ReactNode + setIndex: (index: number) => void + next: () => void + prev: () => void + embla: EmblaCarouselType | null + emblaRef: React.RefCallback | null + withZoom: boolean + withThumbnails: boolean + withFullscreen: boolean + withDownload: boolean + thumbnailsVisible: boolean + toggleThumbnails: () => void + isFullscreen: boolean + toggleFullscreen: () => void + zoomState: { scale: number; isZoomed: boolean } + toggleZoom: () => void + getImageZoomProps: () => Record + onClose: () => void + loop: boolean + closeOnClickOutside: boolean + closeOnSwipeDown: boolean + transitionDuration: number +} + +export const [CollapsedLightboxContextProvider, useCollapsedLightboxContext] = + createSafeContext( + 'Collapsed Lightbox component was not found in tree', + ) diff --git a/web2/src/Component/Lightbox/CollapsedLightbox.module.css b/web2/src/Component/Lightbox/CollapsedLightbox.module.css new file mode 100644 index 0000000..187ce9f --- /dev/null +++ b/web2/src/Component/Lightbox/CollapsedLightbox.module.css @@ -0,0 +1,231 @@ +.root { + --lightbox-transition-duration: 200ms; + --lightbox-overlay-color: rgba(0, 0, 0, 0.9); + --lightbox-z-index: 400; + --lightbox-toolbar-height: 56px; + --lightbox-thumbnails-height: 100px; + + display: contents; +} + +.overlay { + position: fixed; + inset: 0; + z-index: var(--lightbox-z-index); + background-color: var(--lightbox-overlay-color); +} + +.content { + position: fixed; + inset: 0; + z-index: var(--lightbox-z-index); + display: flex; + flex-direction: column; + color: var(--mantine-color-white); + outline: none; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + height: var(--lightbox-toolbar-height); + padding: 0 var(--mantine-spacing-md); + flex-shrink: 0; + z-index: 1; +} + +.toolbarGroup { + display: flex; + align-items: center; + gap: var(--mantine-spacing-xs); +} + +.toolbarButton { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--mantine-radius-sm); + color: var(--mantine-color-white); + opacity: 0.8; + transition: + opacity var(--lightbox-transition-duration) ease, + background-color var(--lightbox-transition-duration) ease; + + @mixin hover { + opacity: 1; + background-color: rgba(255, 255, 255, 0.1); + } +} + +.title { + font-size: var(--mantine-font-size-sm); + color: var(--mantine-color-white); + opacity: 0.8; + user-select: none; +} + +.slides { + flex: 1; + position: relative; + overflow: hidden; + min-height: 0; +} + +.slidesViewport { + height: 100%; + overflow: hidden; +} + +.slidesContainer { + display: flex; + height: 100%; +} + +.slide { + position: relative; + flex: 0 0 100%; + display: flex; + align-items: center; + justify-content: center; + padding: var(--mantine-spacing-md); + min-width: 0; +} + +.slideImage { + max-width: 100%; + max-height: 100%; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; + transition: transform var(--lightbox-transition-duration) ease; + + &[data-zoom-enabled] { + touch-action: none; + } + + &[data-zoomed] { + cursor: grab; + + &:active { + cursor: grabbing; + } + } + + &[data-dragging] { + transition: none; + } + + &[data-zoom-enabled]:not([data-zoomed]) { + cursor: zoom-in; + } +} + +.slideVideo { + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.thumbnails { + display: block; + padding: 0 var(--mantine-spacing-md); + flex-shrink: 0; + overflow: hidden; +} + +.thumbnailsViewport { + width: fit-content; + max-width: 100%; + margin-inline: auto; + height: var(--lightbox-thumbnails-height); + overflow: hidden; +} + +.thumbnailsContainer { + display: flex; + gap: var(--mantine-spacing-xs); + height: 100%; + align-items: center; +} + +.thumbnail { + flex: 0 0 auto; + width: 64px; + height: 64px; + border-radius: var(--mantine-radius-sm); + overflow: hidden; + opacity: 0.5; + border: 2px solid transparent; + cursor: pointer; + transition: + opacity var(--lightbox-transition-duration) ease, + border-color var(--lightbox-transition-duration) ease; + padding: 0; + background: none; + + @mixin hover { + opacity: 0.8; + } + + &[data-active] { + opacity: 1; + border-color: var(--mantine-primary-color-filled); + } +} + +.thumbnailImage { + width: 100%; + height: 100%; + object-fit: cover; +} + +.navigation { + position: absolute; + top: 50%; + display: flex; + justify-content: space-between; + width: 100%; + padding: 0 var(--mantine-spacing-md); + pointer-events: none; + transform: translateY(-50%); + z-index: 1; +} + +.navigationButton { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + color: var(--mantine-color-white); + pointer-events: all; + opacity: 0.7; + transition: opacity var(--lightbox-transition-duration) ease; + + @mixin hover { + opacity: 1; + } + + &[data-inactive] { + opacity: 0.3; + pointer-events: none; + } +} + +.caption { + text-align: center; + padding: var(--mantine-spacing-xs) var(--mantine-spacing-md); + font-size: var(--mantine-font-size-sm); + color: var(--mantine-color-white); + opacity: 0.8; + flex-shrink: 0; +} + +.closeButton { + color: var(--mantine-color-white); +} diff --git a/web2/src/Component/Lightbox/CollapsedLightbox.tsx b/web2/src/Component/Lightbox/CollapsedLightbox.tsx new file mode 100644 index 0000000..007a8f8 --- /dev/null +++ b/web2/src/Component/Lightbox/CollapsedLightbox.tsx @@ -0,0 +1,184 @@ +import { FiTrash } from 'react-icons/fi' +import { useEffect, useRef, useState } from 'react' +import { notifications } from '@mantine/notifications' +import { modals } from '@mantine/modals' +import { + createCloseToolbarItem, + createDownloadToolbarItem, + createThumbnailsToolbarItem, + type LightboxSlideData, + type LightboxProps, + type ToolbarItem, + type ToolbarItemsPayload, + createFullscreenToolbarItem, +} from '@mantine/lightbox' +import { Text } from '@mantine/core' +import { useVideo } from '@gfazioli/mantine-video' +import { Recording } from '../../types' +import { authFetch } from '../../main' +import { LightboxSlides } from './Slides/LightboxSlides' +import { LightboxSlide } from './Slides/LightboxSlide' +import { LightboxToolbar } from './LightboxToolbar' +import { LightboxThumbnails } from './LightboxThumbnails' +import { LightboxRoot } from './LightboxRoot' +import { LightboxNavigation } from './LightboxNavigation' +import { LightboxCaption } from './LightboxCaption' + +export type CollapsedLightboxSlideData = LightboxSlideData & { + recording: Recording & { page: number; index: number } +} + +export interface CollapsedLightboxProps extends LightboxProps { + currentSrc: string + activeRecording: (Recording & { page: number; index: number }) | null + recordings: Map + recordingsCount: Map + onRecordingDeleted?: ( + recording: Recording & { page: number; index: number }, + ) => void | Promise + slides: CollapsedLightboxSlideData[] + toggleInlineFullscreen: () => void +} + +export default function CollapsedLightbox({ + children, + toolbarItems, + ...props +}: CollapsedLightboxProps) { + const payloadRef = useRef(null) + const [thumbnailsVisible, setThumbnailsVisible] = useState(false) + const { canFullscreen } = useVideo() + const openDeleteModal = (filename: string) => + new Promise((resolve) => + modals.openConfirmModal({ + title: 'Delete recording', + children: Delete {filename}?, + labels: { confirm: 'Delete', cancel: 'Cancel' }, + onCancel: () => { + notifications.show({ + title: 'Cancelled', + message: 'Recording deletion cancelled.', + color: 'gray', + }) + resolve(false) + }, + onConfirm: () => resolve(true), + zIndex: 1003, + }), + ) + + useEffect(() => { + if (!props.opened) { + setThumbnailsVisible(false) + } + }, [props.opened]) + + const resolvedToolbarItems = ( + payload: ToolbarItemsPayload, + canFullscreen: boolean, + ): ToolbarItem[] => { + payloadRef.current = payload + + if (typeof toolbarItems === 'function') { + return toolbarItems(payload) + } + + if (toolbarItems) { + return toolbarItems + } + + const toggleThumbnails = () => { + setThumbnailsVisible((visible) => !visible) + } + + return [ + createThumbnailsToolbarItem( + toggleThumbnails, + thumbnailsVisible, + payload.labels, + ), + { + key: 'delete', + icon: , + label: 'Delete recording', + async onClick() { + if (!props.activeRecording) return + if (!(await openDeleteModal(props.activeRecording!.filename))) return + const notification = notifications.show({ + autoClose: false, + title: 'Loading', + message: 'Deleting recording...', + color: 'blue', + }) + + const res = await authFetch( + `/api/recordings/${props.activeRecording.streamId}/${props.activeRecording.filename}`, + { + method: 'DELETE', + }, + ) + notifications.hide(notification) + if (res.ok && (await res.json()).success) { + notifications.show({ + title: 'Success', + message: 'Recording deleted.', + color: 'teal', + }) + props.onRecordingDeleted?.(props.activeRecording) + } else { + notifications.show({ + title: 'Failure', + message: 'Recording deletion failed, please try again.', + color: 'gray', + }) + } + }, + position: 'right', + }, + createDownloadToolbarItem(props.currentSrc, payload.labels), + createFullscreenToolbarItem( + canFullscreen ? payload.toggleFullscreen : props.toggleInlineFullscreen, + payload.isFullscreen, + payload.labels, + ), + createCloseToolbarItem(payload.close, payload.labels), + ] + } + + return ( + // @ts-ignore + + {children ?? ( + <> + + resolvedToolbarItems(payload, canFullscreen) + } + recordings={props.recordings} + /> + + {props.slides.map((slide, index) => ( + + ))} + + + + + + )} + + ) +} + +export type { LightboxProps } diff --git a/web2/src/Component/Lightbox/LightboxCaption.tsx b/web2/src/Component/Lightbox/LightboxCaption.tsx new file mode 100644 index 0000000..ca2e7bb --- /dev/null +++ b/web2/src/Component/Lightbox/LightboxCaption.tsx @@ -0,0 +1,55 @@ +import { + Box, + type BoxProps, + type CompoundStylesApiProps, + type ElementProps, + factory, + type Factory, + useProps, +} from '@mantine/core' +import classes from './CollapsedLightbox.module.css' +import { useCollapsedLightboxContext } from './CollapsedLightbox.context' + +export type LightboxCaptionStylesNames = 'caption' + +export interface LightboxCaptionProps + extends + BoxProps, + CompoundStylesApiProps, + ElementProps<'div'> { + /** Custom caption content, overrides slide caption */ + children?: React.ReactNode +} + +export type LightboxCaptionFactory = Factory<{ + props: LightboxCaptionProps + ref: HTMLDivElement + stylesNames: LightboxCaptionStylesNames + compound: true +}> + +export const LightboxCaption = factory((props) => { + const { classNames, className, style, styles, vars, children, ...others } = + useProps('LightboxCaption', null, props) + + const ctx = useCollapsedLightboxContext() + const currentSlide = ctx.slides[ctx.currentIndex] + const caption = children ?? currentSlide?.caption + + if (!caption) { + return null + } + + return ( + + {caption} + + ) +}) + +LightboxCaption.classes = classes +LightboxCaption.displayName = '@mantine/lightbox/LightboxCaption' diff --git a/web2/src/Component/Lightbox/LightboxCloseButton.tsx b/web2/src/Component/Lightbox/LightboxCloseButton.tsx new file mode 100644 index 0000000..b7007bc --- /dev/null +++ b/web2/src/Component/Lightbox/LightboxCloseButton.tsx @@ -0,0 +1,57 @@ +import { + type BoxProps, + CloseButton, + type CompoundStylesApiProps, + type ElementProps, + factory, + type Factory, + useProps, +} from '@mantine/core' +import classes from './CollapsedLightbox.module.css' +import { useCollapsedLightboxContext } from './CollapsedLightbox.context' + +export type LightboxCloseButtonStylesNames = 'closeButton' + +export interface LightboxCloseButtonProps + extends + BoxProps, + CompoundStylesApiProps, + ElementProps<'button'> {} + +export type LightboxCloseButtonFactory = Factory<{ + props: LightboxCloseButtonProps + ref: HTMLButtonElement + stylesNames: LightboxCloseButtonStylesNames + compound: true +}> + +export const LightboxCloseButton = factory( + (props) => { + const { classNames, className, style, styles, vars, onClick, ...others } = + useProps('LightboxCloseButton', null, props) + + const ctx = useCollapsedLightboxContext() + + return ( + { + onClick?.(event) + ctx.onClose() + }} + /> + ) + }, +) + +LightboxCloseButton.classes = classes +LightboxCloseButton.displayName = '@mantine/lightbox/LightboxCloseButton' diff --git a/web2/src/Component/Lightbox/LightboxNavigation.tsx b/web2/src/Component/Lightbox/LightboxNavigation.tsx new file mode 100644 index 0000000..9b09834 --- /dev/null +++ b/web2/src/Component/Lightbox/LightboxNavigation.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from 'react' +import { + Box, + type BoxProps, + type CompoundStylesApiProps, + type ElementProps, + factory, + type Factory, + UnstyledButton, + useDirection, + useProps, +} from '@mantine/core' +import classes from './CollapsedLightbox.module.css' +import { useCollapsedLightboxContext } from './CollapsedLightbox.context' + +export type LightboxNavigationStylesNames = 'navigation' | 'navigationButton' + +export interface LightboxNavigationProps + extends + BoxProps, + CompoundStylesApiProps, + ElementProps<'div'> {} + +export type LightboxNavigationFactory = Factory<{ + props: LightboxNavigationProps + ref: HTMLDivElement + stylesNames: LightboxNavigationStylesNames + compound: true +}> + +function ChevronLeft() { + return ( + + + + ) +} + +function ChevronRight() { + return ( + + + + ) +} + +export const LightboxNavigation = factory( + (props) => { + const { classNames, className, style, styles, vars, ...others } = useProps( + 'LightboxNavigation', + null, + props, + ) + + const ctx = useCollapsedLightboxContext() + const stylesApiProps = { classNames, styles } + const { dir } = useDirection() + + // Embla is direction-aware, so "previous" is the leading edge of the reading direction – + // the chevrons have to follow it rather than always pointing left/right. + const PrevIcon = dir === 'rtl' ? ChevronRight : ChevronLeft + const NextIcon = dir === 'rtl' ? ChevronLeft : ChevronRight + + const fallbackCanPrev = ctx.loop + ? ctx.slides.length > 1 + : ctx.currentIndex > 0 + const fallbackCanNext = ctx.loop + ? ctx.slides.length > 1 + : ctx.currentIndex < ctx.slides.length - 1 + + const [scrollState, setScrollState] = useState<{ + canPrev: boolean + canNext: boolean + } | null>(null) + + const { embla } = ctx + + useEffect(() => { + if (!embla) { + setScrollState(null) + return undefined + } + + const update = () => + setScrollState({ + canPrev: embla.canScrollPrev(), + canNext: embla.canScrollNext(), + }) + + update() + embla.on('select', update) + embla.on('reInit', update) + embla.on('settle', update) + + return () => { + embla.off('select', update) + embla.off('reInit', update) + embla.off('settle', update) + } + }, [embla]) + + const canPrev = scrollState ? scrollState.canPrev : fallbackCanPrev + const canNext = scrollState ? scrollState.canNext : fallbackCanNext + + return ( + + + + + + + + + + ) + }, +) + +LightboxNavigation.classes = classes +LightboxNavigation.displayName = '@mantine/lightbox/LightboxNavigation' diff --git a/web2/src/Component/Lightbox/LightboxRoot.tsx b/web2/src/Component/Lightbox/LightboxRoot.tsx new file mode 100644 index 0000000..6224c69 --- /dev/null +++ b/web2/src/Component/Lightbox/LightboxRoot.tsx @@ -0,0 +1,789 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import useEmblaCarousel from 'embla-carousel-react' +import type { EmblaOptionsType } from 'embla-carousel' +import { type LightboxLabels } from '@mantine/lightbox' +import { + useFocusReturn, + useFullscreenDocument, + useReducedMotion, + useUncontrolled, +} from '@mantine/hooks' +import { + Box, + type BoxProps, + createVarsResolver, + type ElementProps, + factory, + type Factory, + FocusTrap, + Grid, + type MantineTransition, + OptionalPortal, + RemoveScroll, + type StylesApiProps, + Text, + Transition, + type TransitionOverride, + useDirection, + useMantineTheme, + useProps, + useStyles, + VisuallyHidden, +} from '@mantine/core' +import { formatTime, formatTimestamp } from '../Recordings/RecordingsGrid' +import { Recording } from '../../types' +import { useLightboxZoom } from './hooks/use-lightbox-zoom' +import { useLightboxLockScroll } from './hooks/use-lightbox-lock-scroll' +import { useLightboxKeyboard } from './hooks/use-lightbox-keyboard' +import classes from './CollapsedLightbox.module.css' +import { CollapsedLightboxContextProvider } from './CollapsedLightbox.context' +import { CollapsedLightboxSlideData } from './CollapsedLightbox' + +const DEFAULT_LABELS: LightboxLabels = { + lightboxLabel: 'Gallery', + slideLabel: (index: number, total: number) => `Slide ${index} of ${total}`, + slidesLabel: 'Slides', + previousSlideLabel: 'Previous slide', + nextSlideLabel: 'Next slide', + thumbnailLabel: (index: number) => `Go to slide ${index}`, + enterFullscreenLabel: 'Enter fullscreen', + exitFullscreenLabel: 'Exit fullscreen', + showThumbnailsLabel: 'Show thumbnails', + hideThumbnailsLabel: 'Hide thumbnails', + downloadLabel: 'Download', + closeLabel: 'Close', +} + +export type LightboxRootStylesNames = + | 'root' + | 'overlay' + | 'content' + | 'toolbar' + | 'toolbarGroup' + | 'toolbarButton' + | 'title' + | 'slides' + | 'slidesViewport' + | 'slidesContainer' + | 'slide' + | 'slideImage' + | 'slideVideo' + | 'thumbnails' + | 'thumbnailsViewport' + | 'thumbnailsContainer' + | 'thumbnail' + | 'thumbnailImage' + | 'navigation' + | 'navigationButton' + | 'caption' + | 'closeButton' + +export type LightboxCssVariables = { + root: + | '--lightbox-transition-duration' + | '--lightbox-overlay-color' + | '--lightbox-z-index' + | '--lightbox-toolbar-height' + | '--lightbox-thumbnails-height' +} + +export interface LightboxRootProps + extends + BoxProps, + StylesApiProps, + ElementProps<'div', 'children'> { + recordings: Map + + activeRecording: Recording & { page: number; index: number } + + /** Controls whether the lightbox is opened */ + opened: boolean + + /** Called when the lightbox is closed */ + onClose: () => void + + /** Lightbox content (compound components) */ + children: React.ReactNode + + /** Array of slide data objects */ + slides: CollapsedLightboxSlideData[] + + /** Controlled current slide index */ + currentIndex?: number + + /** Called when the current slide index changes */ + onIndexChange?: (index: number) => void + + /** Labels used in the component, used for accessibility and localization */ + labels?: Partial + + /** Enables image zoom on click/pinch @default false */ + withZoom?: boolean + + /** Shows bottom thumbnail strip @default false */ + withThumbnails?: boolean + + /** Adds fullscreen toggle to toolbar @default false */ + withFullscreen?: boolean + + /** Adds download button to toolbar @default false */ + withDownload?: boolean + + /** Enables infinite loop navigation @default false */ + loop?: boolean + + /** Closes lightbox when the empty space around the slide content is clicked @default false */ + closeOnClickOutside?: boolean + + /** Closes lightbox when swiping down on mobile @default true */ + closeOnSwipeDown?: boolean + + /** Determines whether keyboard shortcuts (arrows, `F`/`T`/`Z`) are active, `Escape` always closes the lightbox @default true */ + withKeyboardEvents?: boolean + + /** Determines whether focus should be returned to the last active element when the lightbox is closed @default true */ + returnFocus?: boolean + + /** Adds a hidden focusable element at the start of the lightbox content – prevents the first toolbar button from receiving visible focus when the lightbox is opened with a pointer. Set to `false` if you need custom focus management. @default true */ + withInitialFocusPlaceholder?: boolean + + /** Determines whether the lightbox should be rendered inside `Portal` @default true */ + withinPortal?: boolean + + /** `z-index` of the overlay and content elements, `400` by default */ + zIndex?: string | number + + /** Transition duration in milliseconds @default 200 */ + transitionDuration?: number + + /** Props passed down to the `Transition` component that animates the content, the overlay always fades. By default, the content is scaled from 95% to 100% while fading in. */ + transitionProps?: TransitionOverride + + /** Additional Embla carousel options. `loop` and `startIndex` are controlled by the + * `loop` and `currentIndex` props and cannot be set here, at the top level or in + * `breakpoints`; a `watchDrag` option is still honored at both levels, but dragging is + * always disabled while the image is zoomed. */ + emblaOptions?: EmblaOptionsType + + /** Maximum zoom scale @default 3 */ + zoomMaxScale?: number + + /** Enables animated slide transitions for programmatic navigation @default false */ + withSlideTransition?: boolean +} + +export type LightboxRootFactory = Factory<{ + props: LightboxRootProps + ref: HTMLDivElement + stylesNames: LightboxRootStylesNames + vars: LightboxCssVariables +}> + +export const lightboxRootDefaultProps = { + withZoom: false, + withThumbnails: false, + withFullscreen: false, + withDownload: false, + loop: false, + closeOnClickOutside: false, + closeOnSwipeDown: true, + withKeyboardEvents: true, + returnFocus: true, + withInitialFocusPlaceholder: true, + withinPortal: true, + transitionDuration: 200, + zoomMaxScale: 3, + withSlideTransition: false, +} satisfies Partial + +type WatchDragOption = EmblaOptionsType['watchDrag'] + +function createBreakpointWatchDrag( + query: string, + zoomIsActive: React.RefObject, + watchDragRef: React.RefObject>, +): WatchDragOption { + return (emblaApi, event) => { + if (zoomIsActive.current) { + return false + } + + const option = watchDragRef.current[query] + + if (typeof option === 'function') { + return option(emblaApi, event) + } + + return option ?? true + } +} + +const defaultContentTransition: MantineTransition = { + common: { transformOrigin: 'center center' }, + in: { opacity: 1, transform: 'scale(1)' }, + out: { opacity: 0, transform: 'scale(0.95)' }, + transitionProperty: 'transform, opacity', +} + +function exitDocumentFullscreen() { + const _document = document as any + const exit = + _document.exitFullscreen || + _document.msExitFullscreen || + _document.webkitExitFullscreen || + _document.mozCancelFullScreen + + if (typeof exit === 'function') { + Promise.resolve(exit.call(_document)).catch(() => { + /* empty */ + }) + } +} + +const varsResolver = createVarsResolver( + (_, { transitionDuration, zIndex }) => ({ + root: { + '--lightbox-transition-duration': `${transitionDuration}ms`, + '--lightbox-overlay-color': undefined, + '--lightbox-z-index': zIndex !== undefined ? `${zIndex}` : undefined, + '--lightbox-toolbar-height': undefined, + '--lightbox-thumbnails-height': undefined, + }, + }), +) + +export const LightboxRoot = factory((_props) => { + const props = useProps('LightboxRoot', lightboxRootDefaultProps, _props) + const { + classNames, + className, + style, + styles, + unstyled, + vars, + opened, + onClose, + children, + slides, + currentIndex, + onIndexChange, + labels, + withZoom, + withThumbnails, + withFullscreen, + withDownload, + loop, + closeOnClickOutside, + closeOnSwipeDown, + withKeyboardEvents, + returnFocus, + withInitialFocusPlaceholder, + withinPortal, + zIndex, + transitionDuration, + recordings, + activeRecording, + transitionProps, + emblaOptions, + zoomMaxScale, + withSlideTransition, + attributes, + mod, + onClick, + ref, + ...others + } = props + + const getStyles = useStyles({ + name: 'Lightbox', + classes, + props, + classNames, + styles, + unstyled, + attributes, + vars, + varsResolver, + }) + + const _labels = useMemo(() => ({ ...DEFAULT_LABELS, ...labels }), [labels]) + + const getStylesRef = useRef(getStyles) + getStylesRef.current = getStyles + + const stableGetStyles = useCallback( + (...args) => getStylesRef.current(...args), + [], + ) + + const [_currentIndex, setCurrentIndex] = useUncontrolled({ + value: currentIndex, + defaultValue: 0, + finalValue: 0, + onChange: onIndexChange, + }) + + const zoomIsActive = useRef(false) + const currentIndexRef = useRef(_currentIndex) + const setCurrentIndexRef = useRef(setCurrentIndex) + + // Embla emits `select` synchronously from `scrollTo`. When the scroll was requested for + // an index the consumer already knows about, that echo is suppressed for the duration + // of the call so `onIndexChange` is not reported twice for one navigation. + const suppressSelectRef = useRef(null) + const fullscreenRequestedRef = useRef(false) + const ownsFullscreenRef = useRef(false) + + // A `requestFullscreen` that is still in flight, and whether it should be undone as soon + // as it settles because the lightbox closed in the meantime. + const pendingFullscreenRef = useRef | null>(null) + const exitOnSettleRef = useRef(false) + + // Embla compares options by function source, so the `watchDrag` closure above must stay + // textually stable – the current user options are read through this ref instead. + const emblaOptionsRef = useRef(emblaOptions) + + const [startIndex, setStartIndex] = useState(_currentIndex) + const [prevOpened, setPrevOpened] = useState(opened) + + if (opened !== prevOpened) { + setPrevOpened(opened) + if (opened) { + setStartIndex(_currentIndex) + } + } + + const { dir } = useDirection() + + const breakpointWatchDragRef = useRef>({}) + + const resolvedEmblaOptions = useMemo(() => { + if (!emblaOptions?.breakpoints) { + breakpointWatchDragRef.current = {} + return emblaOptions + } + + const watchDragOptions: Record = {} + + const breakpoints = Object.fromEntries( + Object.entries(emblaOptions.breakpoints).map(([query, value]) => { + const { + loop: _loop, + startIndex: _startIndex, + watchDrag, + ...rest + } = value ?? {} + watchDragOptions[query] = watchDrag + + return [ + query, + watchDrag === undefined + ? rest + : { + ...rest, + watchDrag: createBreakpointWatchDrag( + query, + zoomIsActive, + breakpointWatchDragRef, + ), + }, + ] + }), + ) + + breakpointWatchDragRef.current = watchDragOptions + return { ...emblaOptions, breakpoints } + }, [emblaOptions]) + + const [emblaRef, embla] = useEmblaCarousel({ + // Placed before the user options so `emblaOptions.direction` can still override it, + // the same way `@mantine/carousel` wires direction up. + direction: dir, + ...resolvedEmblaOptions, + loop, + startIndex, + watchDrag: (emblaApi, event) => { + // The zoom gesture lock always wins – dragging a zoomed image pans it + // instead of changing the slide. + if (zoomIsActive.current) { + return false + } + + const userWatchDrag = emblaOptionsRef.current?.watchDrag + if (typeof userWatchDrag === 'function') { + return userWatchDrag(emblaApi, event) + } + + return userWatchDrag ?? true + }, + }) + + const [thumbnailsVisible, setThumbnailsVisible] = useState(!!withThumbnails) + const [prevWithThumbnails, setPrevWithThumbnails] = useState(withThumbnails) + + // Toggling the feature while the lightbox is open used to leave the strip expanded with + // no way to collapse it – the toolbar button that toggles it is only rendered while + // `withThumbnails` is set. + if (withThumbnails !== prevWithThumbnails) { + setPrevWithThumbnails(withThumbnails) + setThumbnailsVisible(!!withThumbnails) + } + + const { toggle: toggleFullscreenFn, fullscreen: isFullscreen } = + useFullscreenDocument() + + const toggleThumbnails = useCallback(() => { + setThumbnailsVisible((prev) => !prev) + }, []) + + const toggleFullscreen = useCallback(() => { + fullscreenRequestedRef.current = !isFullscreen + if (isFullscreen) { + ownsFullscreenRef.current = false + } + + const request = toggleFullscreenFn() + .then(() => { + // Closing while `requestFullscreen` is still pending cannot exit anything yet – the + // browser has not entered fullscreen. The exit has to be retried once the request + // settles, or the page is left fullscreen with no lightbox on top of it. + if ( + pendingFullscreenRef.current === request && + exitOnSettleRef.current + ) { + exitOnSettleRef.current = false + exitDocumentFullscreen() + } + }) + .catch(() => { + fullscreenRequestedRef.current = false + exitOnSettleRef.current = false + }) + .finally(() => { + if (pendingFullscreenRef.current === request) { + pendingFullscreenRef.current = null + } + }) + + pendingFullscreenRef.current = request + }, [toggleFullscreenFn, isFullscreen]) + + useEffect(() => { + if (!isFullscreen) { + fullscreenRequestedRef.current = false + ownsFullscreenRef.current = false + } else if (fullscreenRequestedRef.current) { + fullscreenRequestedRef.current = false + ownsFullscreenRef.current = true + } + }, [isFullscreen]) + + const exitOwnedFullscreen = useCallback(() => { + if (!ownsFullscreenRef.current && !fullscreenRequestedRef.current) { + return + } + + // A request that has not settled yet cannot be exited now – it is exited when it does. + if ( + pendingFullscreenRef.current !== null && + fullscreenRequestedRef.current + ) { + exitOnSettleRef.current = true + } + + fullscreenRequestedRef.current = false + ownsFullscreenRef.current = false + exitDocumentFullscreen() + }, []) + + const zoom = useLightboxZoom({ + enabled: !!withZoom, + maxScale: zoomMaxScale!, + currentIndex: _currentIndex, + }) + + useEffect(() => { + currentIndexRef.current = _currentIndex + setCurrentIndexRef.current = setCurrentIndex + zoomIsActive.current = zoom.zoomState.isZoomed + emblaOptionsRef.current = emblaOptions + }) + + const { resetZoom } = zoom + + useEffect(() => { + if (!opened) { + resetZoom() + setThumbnailsVisible(!!withThumbnails) + exitOwnedFullscreen() + } + }, [opened, resetZoom, withThumbnails, exitOwnedFullscreen]) + + useEffect(() => exitOwnedFullscreen, [exitOwnedFullscreen]) + + const theme = useMantineTheme() + const shouldReduceMotion = useReducedMotion() + const reduceMotion = theme.respectReducedMotion ? shouldReduceMotion : false + const animateSlides = !!withSlideTransition && !reduceMotion + + const handleNext = useCallback(() => { + embla?.scrollNext(!animateSlides) + }, [embla, animateSlides]) + + const handlePrev = useCallback(() => { + embla?.scrollPrev(!animateSlides) + }, [embla, animateSlides]) + + useLightboxKeyboard({ + opened, + enabled: withKeyboardEvents!, + dir, + onClose, + onNext: handleNext, + onPrev: handlePrev, + onToggleFullscreen: withFullscreen ? toggleFullscreen : undefined, + onToggleThumbnails: withThumbnails ? toggleThumbnails : undefined, + onToggleZoom: withZoom ? zoom.toggleZoom : undefined, + onZoomPan: withZoom ? zoom.panZoom : undefined, + }) + + useEffect(() => { + if (!embla) { + return undefined + } + + const onSelect = () => { + const selected = embla.selectedScrollSnap() + + if (selected !== suppressSelectRef.current) { + setCurrentIndexRef.current(selected) + } + } + + const onReInit = () => { + if (embla.selectedScrollSnap() !== currentIndexRef.current) { + suppressSelectRef.current = currentIndexRef.current + embla.scrollTo(currentIndexRef.current, true) + suppressSelectRef.current = null + } + } + + embla.on('select', onSelect) + embla.on('reInit', onReInit) + return () => { + embla.off('select', onSelect) + embla.off('reInit', onReInit) + } + }, [embla]) + + useEffect(() => { + if (embla && opened && embla.selectedScrollSnap() !== _currentIndex) { + // `_currentIndex` is already what the consumer asked for – do not echo it back + suppressSelectRef.current = _currentIndex + embla.scrollTo(_currentIndex, !animateSlides) + suppressSelectRef.current = null + } + }, [_currentIndex, embla, opened, animateSlides]) + + // Slides can be replaced or shortened while the lightbox is open – without this the + // index can point past the end, leaving no active slide and a counter like "3 / 2". + useEffect(() => { + if (slides.length > 0 && _currentIndex > slides.length - 1) { + setCurrentIndexRef.current(slides.length - 1) + } + }, [slides.length, _currentIndex]) + + useFocusReturn({ opened, shouldReturnFocus: returnFocus }) + + const shouldLockScroll = useLightboxLockScroll({ + opened, + exitDuration: + typeof transitionProps?.exitDuration === 'number' + ? transitionProps.exitDuration + : typeof transitionProps?.duration === 'number' + ? transitionProps.duration + : transitionDuration, + exitDelay: + typeof transitionProps?.exitDelay === 'number' + ? transitionProps.exitDelay + : 0, + }) + + const transition: TransitionOverride = { + transition: defaultContentTransition, + duration: transitionDuration, + timingFunction: 'ease', + ...transitionProps, + } + + const overlayTransition: TransitionOverride = { + transition: 'fade', + duration: transition.duration, + exitDuration: transition.exitDuration, + timingFunction: transition.timingFunction, + enterDelay: transition.enterDelay, + exitDelay: transition.exitDelay, + keepMounted: transition.keepMounted, + } + + const setIndex = useCallback( + (index: number) => { + setCurrentIndexRef.current(index) + suppressSelectRef.current = index + embla?.scrollTo(index, !animateSlides) + suppressSelectRef.current = null + }, + [embla, animateSlides], + ) + + const currentSlide = slides[_currentIndex] + const currentSlideLabel = + currentSlide?.type === 'video' + ? currentSlide.label + : currentSlide?.type === 'custom' + ? undefined + : currentSlide?.alt + + const currentTitle = useMemo(() => { + if (!activeRecording) return '' + const streamRecordings = recordings.get(activeRecording.streamId)! + const recording = + streamRecordings[props.activeRecording.page - 1][ + props.activeRecording.index + ] + return ( + + {recording.nickname ? ( + + {recording.nickname} —  + + ) : null} + + {formatTime(recording.duration)} —  + + + {formatTimestamp(recording.filename)} + + + ) + }, [recordings, activeRecording]) + + const contextValue = useMemo( + () => ({ + getStyles: stableGetStyles, + labels: _labels, + opened, + slides, + currentIndex: _currentIndex, + setIndex, + next: handleNext, + prev: handlePrev, + emblaRef, + embla: embla ?? null, + currentTitle, + withZoom: !!withZoom, + withThumbnails: !!withThumbnails, + withFullscreen: !!withFullscreen, + withDownload: !!withDownload, + thumbnailsVisible, + toggleThumbnails, + isFullscreen, + toggleFullscreen, + zoomState: zoom.zoomState, + toggleZoom: zoom.toggleZoom, + getImageZoomProps: zoom.getImageProps, + onClose, + loop: !!loop, + closeOnClickOutside: !!closeOnClickOutside, + closeOnSwipeDown: !!closeOnSwipeDown, + transitionDuration: transitionDuration!, + }), + [ + stableGetStyles, + _labels, + opened, + slides, + _currentIndex, + setIndex, + handleNext, + handlePrev, + embla, + emblaRef, + withZoom, + withThumbnails, + withFullscreen, + withDownload, + thumbnailsVisible, + toggleThumbnails, + isFullscreen, + toggleFullscreen, + zoom, + onClose, + loop, + closeOnClickOutside, + closeOnSwipeDown, + transitionDuration, + ], + ) + + return ( + + + + + + {(overlayStyles) => ( + + )} + + + + {(contentStyles) => ( + + { + onClick?.(event) + if ( + closeOnClickOutside && + event.target === event.currentTarget + ) { + onClose() + } + }} + > + {withInitialFocusPlaceholder && } + + + {slides.length > 0 + ? `${_labels.slideLabel(_currentIndex + 1, slides.length)}${currentSlideLabel ? `: ${currentSlideLabel}` : ''}` + : null} + + {children} + + + )} + + + + + + ) +}) + +LightboxRoot.classes = classes +LightboxRoot.varsResolver = varsResolver +LightboxRoot.displayName = '@mantine/lightbox/LightboxRoot' diff --git a/web2/src/Component/Lightbox/LightboxThumbnails.tsx b/web2/src/Component/Lightbox/LightboxThumbnails.tsx new file mode 100644 index 0000000..68cc53e --- /dev/null +++ b/web2/src/Component/Lightbox/LightboxThumbnails.tsx @@ -0,0 +1,132 @@ +import { useEffect } from 'react' +import useEmblaCarousel from 'embla-carousel-react' +import { type LightboxCustomSlide } from '@mantine/lightbox' +import { + type BoxProps, + Collapse, + type CompoundStylesApiProps, + type ElementProps, + factory, + type Factory, + UnstyledButton, + useDirection, + useProps, +} from '@mantine/core' +import classes from './CollapsedLightbox.module.css' +import { useCollapsedLightboxContext } from './CollapsedLightbox.context' + +export type LightboxThumbnailsStylesNames = + | 'thumbnails' + | 'thumbnailsViewport' + | 'thumbnailsContainer' + | 'thumbnail' + | 'thumbnailImage' + +export interface LightboxThumbnailsProps + extends + BoxProps, + CompoundStylesApiProps, + ElementProps<'div', 'onTransitionEnd' | 'onTransitionStart'> {} + +export type LightboxThumbnailsFactory = Factory<{ + props: LightboxThumbnailsProps + ref: HTMLDivElement + stylesNames: LightboxThumbnailsStylesNames + compound: true +}> + +export const LightboxThumbnails = factory( + (props) => { + const { classNames, className, style, styles, vars, ...others } = useProps( + 'LightboxThumbnails', + null, + props, + ) + + const ctx = useCollapsedLightboxContext() + const stylesApiProps = { classNames, styles } + + const { dir } = useDirection() + + // The second carousel is only built when the strip can actually be shown – `active: false` + // makes Embla skip its listeners and measurements entirely, so a lightbox without + // thumbnails does not pay for an instance it never renders. + const [thumbsRef, thumbsEmbla] = useEmblaCarousel({ + containScroll: 'keepSnaps', + dragFree: true, + direction: dir, + active: ctx.withThumbnails, + }) + + useEffect(() => { + if (thumbsEmbla) { + thumbsEmbla.scrollTo(ctx.currentIndex) + } + }, [ctx.currentIndex, thumbsEmbla]) + + if (!ctx.withThumbnails) { + return null + } + + const thumbnails = ctx.slides.map((slide, index) => { + const active = index === ctx.currentIndex + const thumbSrc = + slide.thumbSrc ?? + (slide.type === 'video' ? slide.poster : undefined) ?? + (slide.type === 'image' || slide.type === undefined + ? slide.src + : undefined) + + return ( + ctx.setIndex(index)} + > + {(slide as LightboxCustomSlide).renderThumb ? ( + (slide as LightboxCustomSlide).renderThumb!() + ) : thumbSrc ? ( + + ) : null} + + ) + }) + + return ( + +
+
+ {thumbnails} +
+
+
+ ) + }, +) + +LightboxThumbnails.classes = classes +LightboxThumbnails.displayName = '@mantine/lightbox/LightboxThumbnails' diff --git a/web2/src/Component/Lightbox/LightboxToolbar.tsx b/web2/src/Component/Lightbox/LightboxToolbar.tsx new file mode 100644 index 0000000..866fe4e --- /dev/null +++ b/web2/src/Component/Lightbox/LightboxToolbar.tsx @@ -0,0 +1,161 @@ +import { + type ToolbarItems, + type ToolbarItem, + createThumbnailsToolbarItem, + createFullscreenToolbarItem, + createDownloadToolbarItem, + createCloseToolbarItem, +} from '@mantine/lightbox' +import { useViewportSize } from '@mantine/hooks' +import { + Box, + type BoxProps, + type CompoundStylesApiProps, + type ElementProps, + type Factory, + factory, + UnstyledButton, + useProps, +} from '@mantine/core' +import RecordingRenamePopover from '../Recordings/RecordingRenamePopover' +import { Recording } from '../../types' +import classes from './CollapsedLightbox.module.css' +import { useCollapsedLightboxContext } from './CollapsedLightbox.context' + +export type LightboxToolbarStylesNames = + | 'toolbar' + | 'toolbarGroup' + | 'toolbarButton' + | 'title' + +export interface LightboxToolbarProps + extends + BoxProps, + CompoundStylesApiProps, + ElementProps<'div'> { + /** Custom toolbar items, overrides default toolbar actions. Can be a function that receives the current lightbox state and handlers. */ + toolbarItems?: ToolbarItems + recordings: Map +} + +export type LightboxToolbarFactory = Factory<{ + props: LightboxToolbarProps + ref: HTMLDivElement + stylesNames: LightboxToolbarStylesNames + compound: true +}> + +export const LightboxToolbar = factory((props) => { + const { + classNames, + className, + style, + styles, + vars, + toolbarItems, + ...others + } = useProps('LightboxToolbar', null, props) + + const ctx = useCollapsedLightboxContext() + const stylesApiProps = { classNames, styles } + const currentSlide = ctx.slides[ctx.currentIndex] + const { width } = useViewportSize() + + const defaultItems: ToolbarItem[] = [] + + if (ctx.withThumbnails) { + defaultItems.push( + createThumbnailsToolbarItem( + ctx.toggleThumbnails, + ctx.thumbnailsVisible, + ctx.labels, + ), + ) + } + + if (ctx.withFullscreen) { + defaultItems.push( + createFullscreenToolbarItem( + ctx.toggleFullscreen, + ctx.isFullscreen, + ctx.labels, + ), + ) + } + + if (ctx.withDownload && currentSlide && currentSlide.type !== 'custom') { + defaultItems.push(createDownloadToolbarItem(currentSlide.src, ctx.labels)) + } + + defaultItems.push(createCloseToolbarItem(ctx.onClose, ctx.labels)) + + const items = + typeof toolbarItems === 'function' + ? toolbarItems({ + slides: ctx.slides, + currentIndex: ctx.currentIndex, + setIndex: ctx.setIndex, + next: ctx.next, + prev: ctx.prev, + close: ctx.onClose, + thumbnailsVisible: ctx.thumbnailsVisible, + toggleThumbnails: ctx.toggleThumbnails, + isFullscreen: ctx.isFullscreen, + toggleFullscreen: ctx.toggleFullscreen, + zoomed: ctx.zoomState.isZoomed, + toggleZoom: ctx.toggleZoom, + labels: ctx.labels, + }) + : (toolbarItems ?? defaultItems) + const leftItems = items.filter((item) => item.position === 'left') + const rightItems = items.filter((item) => item.position !== 'left') + + return ( + +
+ {leftItems.map((item) => ( + + {item.icon} + + ))} +
+ + {width > 600 && ( + + {ctx.currentTitle ?? null} + + )} + +
+ + {rightItems.map((item) => ( + + {item.icon} + + ))} +
+
+ ) +}) + +LightboxToolbar.classes = classes +LightboxToolbar.displayName = '@mantine/lightbox/LightboxToolbar' diff --git a/web2/src/Component/Lightbox/Slides/ImageSlide.tsx b/web2/src/Component/Lightbox/Slides/ImageSlide.tsx new file mode 100644 index 0000000..a9997d7 --- /dev/null +++ b/web2/src/Component/Lightbox/Slides/ImageSlide.tsx @@ -0,0 +1,31 @@ +import type { LightboxImageSlide } from '@mantine/lightbox' +import { useCollapsedLightboxContext } from '../CollapsedLightbox.context' +import type { LightboxSlideStylesApiProps } from './LightboxSlide' + +interface ImageSlideProps { + slide: LightboxImageSlide + active: boolean + stylesApiProps?: LightboxSlideStylesApiProps +} + +export function ImageSlide({ slide, active, stylesApiProps }: ImageSlideProps) { + const ctx = useCollapsedLightboxContext() + const { style: zoomStyle, ...zoomProps } = + active && ctx.withZoom ? ctx.getImageZoomProps() : { style: undefined } + + return ( + {slide.alt + ) +} + +ImageSlide.displayName = '@mantine/lightbox/ImageSlide' diff --git a/web2/src/Component/Lightbox/Slides/LightboxSlide.tsx b/web2/src/Component/Lightbox/Slides/LightboxSlide.tsx new file mode 100644 index 0000000..d4b7efc --- /dev/null +++ b/web2/src/Component/Lightbox/Slides/LightboxSlide.tsx @@ -0,0 +1,110 @@ +import { + Box, + type BoxProps, + type CompoundStylesApiProps, + type ElementProps, + factory, + type Factory, + useProps, +} from '@mantine/core' +import classes from '../CollapsedLightbox.module.css' +import { useCollapsedLightboxContext } from '../CollapsedLightbox.context' +import { CollapsedLightboxSlideData } from '../CollapsedLightbox' +import { VideoSlide } from './VideoSlide' +import { ImageSlide } from './ImageSlide' + +export type LightboxSlideStylesNames = 'slide' | 'slideImage' | 'slideVideo' + +export interface LightboxSlideStylesApiProps { + classNames?: CompoundStylesApiProps['classNames'] + styles?: CompoundStylesApiProps['styles'] +} + +export interface LightboxSlideProps + extends + BoxProps, + CompoundStylesApiProps, + ElementProps<'div'> { + /** Slide data object */ + slide: CollapsedLightboxSlideData + + /** Index of the slide in the slides array */ + index: number +} + +export type LightboxSlideFactory = Factory<{ + props: LightboxSlideProps + ref: HTMLDivElement + stylesNames: LightboxSlideStylesNames + compound: true +}> + +export const LightboxSlide = factory((props) => { + const { + classNames, + className, + style, + styles, + vars, + slide, + index, + onClick, + ...others + } = useProps('LightboxSlide', null, props) + + const ctx = useCollapsedLightboxContext() + const active = ctx.currentIndex === index + + const stylesApiProps = { classNames, styles } + + const renderContent = () => { + if (slide.type === 'video') { + return ( + + ) + } + + if (slide.type === 'custom') { + return slide.render({ active }) + } + + return ( + + ) + } + + return ( + { + onClick?.(event) + if ( + ctx.closeOnClickOutside && + !event.defaultPrevented && + event.target === event.currentTarget + ) { + ctx.onClose() + } + }} + > + {renderContent()} + + ) +}) + +LightboxSlide.classes = classes +LightboxSlide.displayName = '@mantine/lightbox/LightboxSlide' diff --git a/web2/src/Component/Lightbox/Slides/LightboxSlides.tsx b/web2/src/Component/Lightbox/Slides/LightboxSlides.tsx new file mode 100644 index 0000000..8745a10 --- /dev/null +++ b/web2/src/Component/Lightbox/Slides/LightboxSlides.tsx @@ -0,0 +1,93 @@ +import { useMergedRef } from '@mantine/hooks' +import { + Box, + type BoxProps, + type CompoundStylesApiProps, + type ElementProps, + factory, + type Factory, + useProps, +} from '@mantine/core' +import { useLightboxSwipeClose } from '../hooks/use-lightbox-swipe-close' +import classes from '../CollapsedLightbox.module.css' +import { useCollapsedLightboxContext } from '../CollapsedLightbox.context' + +export type LightboxSlidesStylesNames = + | 'slides' + | 'slidesViewport' + | 'slidesContainer' + +export interface LightboxSlidesProps + extends + BoxProps, + CompoundStylesApiProps, + ElementProps<'div'> { + /** Embla carousel ref callback */ + emblaRef?: React.RefCallback + + /** Slide components */ + children: React.ReactNode +} + +export type LightboxSlidesFactory = Factory<{ + props: LightboxSlidesProps + ref: HTMLDivElement + stylesNames: LightboxSlidesStylesNames + compound: true +}> + +export const LightboxSlides = factory((props) => { + const { + classNames, + className, + style, + styles, + vars, + emblaRef, + children, + onTouchStart, + onTouchEnd, + ...others + } = useProps('LightboxSlides', null, props) + + const ctx = useCollapsedLightboxContext() + const stylesApiProps = { classNames, styles } + + const viewportRef = useMergedRef(ctx.emblaRef, emblaRef) + + const { handleTouchStart, handleTouchEnd } = useLightboxSwipeClose({ + enabled: ctx.closeOnSwipeDown, + onClose: ctx.onClose, + isZoomed: ctx.zoomState.isZoomed, + }) + + return ( + { + onTouchStart?.(event) + handleTouchStart(event) + }} + onTouchEnd={(event) => { + onTouchEnd?.(event) + handleTouchEnd(event) + }} + > +
+
+ {children} +
+
+
+ ) +}) + +LightboxSlides.classes = classes +LightboxSlides.displayName = '@mantine/lightbox/LightboxSlides' diff --git a/web2/src/Component/Lightbox/Slides/VideoSlide.tsx b/web2/src/Component/Lightbox/Slides/VideoSlide.tsx new file mode 100644 index 0000000..34dfdd7 --- /dev/null +++ b/web2/src/Component/Lightbox/Slides/VideoSlide.tsx @@ -0,0 +1,67 @@ +import { useEffect, useRef } from 'react' +import type { LightboxVideoSlide } from '@mantine/lightbox' +import { useCollapsedLightboxContext } from '../CollapsedLightbox.context' +import type { LightboxSlideStylesApiProps } from './LightboxSlide' + +interface VideoSlideProps { + slide: LightboxVideoSlide + active: boolean + stylesApiProps?: LightboxSlideStylesApiProps +} + +export function VideoSlide({ slide, active, stylesApiProps }: VideoSlideProps) { + const ctx = useCollapsedLightboxContext() + const videoRef = useRef(null) + + // With `transitionProps.keepMounted` the slides stay mounted while the lightbox is + // closed, so playback has to follow `opened` as well – otherwise a hidden autoplay video + // starts on its own and keeps playing after the lightbox is dismissed. + const playing = active && ctx.opened + + useEffect(() => { + const video = videoRef.current + + if (!video) { + return + } + + if (!playing) { + video.pause() + return + } + + // All slides are mounted up front, so the `autoPlay` attribute only ever fires for the + // slide that happens to be active on mount – playback has to be started explicitly + // when an already mounted video becomes the current slide. + if (slide.autoPlay) { + video.play().catch(() => {}) + } + }, [playing, slide.autoPlay]) + + return ( + + ) +} + +VideoSlide.displayName = '@mantine/lightbox/VideoSlide' diff --git a/web2/src/Component/Lightbox/hooks/use-lightbox-keyboard.ts b/web2/src/Component/Lightbox/hooks/use-lightbox-keyboard.ts new file mode 100644 index 0000000..68db9c5 --- /dev/null +++ b/web2/src/Component/Lightbox/hooks/use-lightbox-keyboard.ts @@ -0,0 +1,168 @@ +import { useEffect } from 'react' + +interface UseLightboxKeyboardInput { + opened: boolean + enabled: boolean + /** Text direction of the lightbox, arrow keys follow it the same way the navigation buttons do */ + dir: 'ltr' | 'rtl' + onClose: () => void + onNext: () => void + onPrev: () => void + onToggleFullscreen?: () => void + onToggleThumbnails?: () => void + onToggleZoom?: () => void + onZoomPan?: (deltaX: number, deltaY: number) => boolean +} + +/** Elements that consume typing wherever they are, including the lightbox chrome */ +const TEXT_ENTRY_SELECTOR = + 'input, textarea, select, video, audio, [contenteditable]:not([contenteditable="false"])' + +/** + * Elements that consume arrow keys and letter shortcuts only inside slide content – the + * lightbox toolbar and navigation are buttons too, and keyboard shortcuts have to keep + * working while one of them has focus. + */ +const SLIDE_WIDGET_SELECTOR = [ + 'button', + 'a[href]', + '[role="button"]', + '[role="link"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="menuitem"]', + '[role="option"]', + '[role="slider"]', + '[role="spinbutton"]', + '[role="switch"]', + '[role="tab"]', + '[role="textbox"]', + '[role="combobox"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menubar"]', + '[role="tablist"]', + '[role="tree"]', + '[role="treeitem"]', + '[role="grid"]', + '[role="gridcell"]', +].join(', ') + +const ZOOM_PAN_STEP = 50 + +export function useLightboxKeyboard({ + opened, + enabled, + dir, + onClose, + onNext, + onPrev, + onToggleFullscreen, + onToggleThumbnails, + onToggleZoom, + onZoomPan, +}: UseLightboxKeyboardInput) { + useEffect(() => { + if (!opened) { + return undefined + } + + // Slides advance along the reading direction, so in RTL the next slide is the one to the + // left – the arrow keys follow the same rule as the navigation chevrons, which are + // already mirrored. Zoom panning stays physical: an arrow always moves the image the way + // it points. + const onLeadingArrow = dir === 'rtl' ? onNext : onPrev + const onTrailingArrow = dir === 'rtl' ? onPrev : onNext + + const handler = (event: KeyboardEvent) => { + if (event.defaultPrevented) { + return + } + + if (event.key === 'Escape') { + event.preventDefault() + onClose() + return + } + + if (!enabled || event.ctrlKey || event.metaKey || event.altKey) { + return + } + + if (event.target instanceof Element) { + if (event.target.closest(TEXT_ENTRY_SELECTOR)) { + return + } + + if ( + event.target.closest('[data-lightbox-slide]') && + event.target.closest(SLIDE_WIDGET_SELECTOR) + ) { + return + } + } + + switch (event.key) { + case 'ArrowLeft': + event.preventDefault() + if (!onZoomPan?.(ZOOM_PAN_STEP, 0)) { + onLeadingArrow() + } + break + case 'ArrowRight': + event.preventDefault() + if (!onZoomPan?.(-ZOOM_PAN_STEP, 0)) { + onTrailingArrow() + } + break + case 'ArrowUp': + if (onZoomPan?.(0, ZOOM_PAN_STEP)) { + event.preventDefault() + } + break + case 'ArrowDown': + if (onZoomPan?.(0, -ZOOM_PAN_STEP)) { + event.preventDefault() + } + break + case 'f': + case 'F': + if (onToggleFullscreen) { + event.preventDefault() + onToggleFullscreen() + } + break + case 't': + case 'T': + if (onToggleThumbnails) { + event.preventDefault() + onToggleThumbnails() + } + break + case 'z': + case 'Z': + if (onToggleZoom) { + event.preventDefault() + onToggleZoom() + } + break + default: + break + } + } + + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [ + opened, + enabled, + dir, + onClose, + onNext, + onPrev, + onToggleFullscreen, + onToggleThumbnails, + onToggleZoom, + onZoomPan, + ]) +} diff --git a/web2/src/Component/Lightbox/hooks/use-lightbox-lock-scroll.ts b/web2/src/Component/Lightbox/hooks/use-lightbox-lock-scroll.ts new file mode 100644 index 0000000..589fda1 --- /dev/null +++ b/web2/src/Component/Lightbox/hooks/use-lightbox-lock-scroll.ts @@ -0,0 +1,46 @@ +import { useEffect, useRef, useState } from 'react' +import { useReducedMotion } from '@mantine/hooks' +import { useMantineTheme } from '@mantine/core' + +interface UseLightboxLockScrollInput { + opened: boolean + exitDuration: number + exitDelay: number +} + +export function useLightboxLockScroll({ + opened, + exitDuration, + exitDelay, +}: UseLightboxLockScrollInput) { + const [unlocked, setUnlocked] = useState(!opened) + const [prevOpened, setPrevOpened] = useState(opened) + const timeout = useRef(-1) + const theme = useMantineTheme() + const shouldReduceMotion = useReducedMotion() + const reduceMotion = theme.respectReducedMotion ? shouldReduceMotion : false + const unlockDelay = exitDelay + (reduceMotion ? 0 : exitDuration) + + // Opening arms the lock in the same render rather than one commit later, so the page + // cannot scroll in the frame between mounting the lightbox and the effect running. + if (opened !== prevOpened) { + setPrevOpened(opened) + if (opened) { + setUnlocked(false) + } + } + + useEffect(() => { + if (opened || unlockDelay === 0) { + window.clearTimeout(timeout.current) + return undefined + } + + // The lock is released only after the exit transition has played out, otherwise the + // page jumps by the scrollbar width while the lightbox is still fading out. + timeout.current = window.setTimeout(() => setUnlocked(true), unlockDelay) + return () => window.clearTimeout(timeout.current) + }, [opened, unlockDelay]) + + return opened || (!unlocked && unlockDelay > 0) +} diff --git a/web2/src/Component/Lightbox/hooks/use-lightbox-swipe-close.ts b/web2/src/Component/Lightbox/hooks/use-lightbox-swipe-close.ts new file mode 100644 index 0000000..c922677 --- /dev/null +++ b/web2/src/Component/Lightbox/hooks/use-lightbox-swipe-close.ts @@ -0,0 +1,62 @@ +import { useCallback, useRef } from 'react' + +interface UseLightboxSwipeCloseInput { + enabled: boolean + onClose: () => void + isZoomed: boolean +} + +const SWIPE_THRESHOLD = 100 + +export function useLightboxSwipeClose({ + enabled, + onClose, + isZoomed, +}: UseLightboxSwipeCloseInput) { + const touchStart = useRef<{ x: number; y: number } | null>(null) + + const handleTouchStart = useCallback( + (event: React.TouchEvent) => { + if (!enabled || isZoomed) { + return + } + if (event.touches.length !== 1) { + touchStart.current = null + return + } + touchStart.current = { + x: event.touches[0].clientX, + y: event.touches[0].clientY, + } + }, + [enabled, isZoomed], + ) + + const handleTouchEnd = useCallback( + (event: React.TouchEvent) => { + if ( + !enabled || + isZoomed || + !touchStart.current || + event.changedTouches.length !== 1 + ) { + touchStart.current = null + return + } + + const deltaY = event.changedTouches[0].clientY - touchStart.current.y + const deltaX = Math.abs( + event.changedTouches[0].clientX - touchStart.current.x, + ) + + if (deltaY > SWIPE_THRESHOLD && deltaX < deltaY) { + onClose() + } + + touchStart.current = null + }, + [enabled, isZoomed, onClose], + ) + + return { handleTouchStart, handleTouchEnd } +} diff --git a/web2/src/Component/Lightbox/hooks/use-lightbox-zoom.ts b/web2/src/Component/Lightbox/hooks/use-lightbox-zoom.ts new file mode 100644 index 0000000..2100372 --- /dev/null +++ b/web2/src/Component/Lightbox/hooks/use-lightbox-zoom.ts @@ -0,0 +1,383 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +interface UseLightboxZoomInput { + enabled: boolean + maxScale: number + currentIndex: number +} + +interface ZoomState { + scale: number + translateX: number + translateY: number + isZoomed: boolean +} + +const INITIAL_ZOOM_STATE: ZoomState = { + scale: 1, + translateX: 0, + translateY: 0, + isZoomed: false, +} + +function clampTranslate( + state: ZoomState, + image: HTMLImageElement | null, +): ZoomState { + if (!image) { + return state + } + + const maxX = (image.offsetWidth * (state.scale - 1)) / 2 + const maxY = (image.offsetHeight * (state.scale - 1)) / 2 + + return { + ...state, + translateX: Math.max(-maxX, Math.min(maxX, state.translateX)), + translateY: Math.max(-maxY, Math.min(maxY, state.translateY)), + } +} + +export function useLightboxZoom({ + enabled, + maxScale, + currentIndex, +}: UseLightboxZoomInput) { + const [zoomState, setZoomState] = useState(INITIAL_ZOOM_STATE) + const [isDragging, setIsDragging] = useState(false) + const imageRef = useRef(null) + const isZoomedRef = useRef(false) + const didDrag = useRef(false) + const dragStart = useRef({ x: 0, y: 0 }) + const translateStart = useRef({ x: 0, y: 0 }) + const lastPinchDistance = useRef(null) + const lastPointerType = useRef('mouse') + const lastClickPointerType = useRef('mouse') + const [isAdjusting, setIsAdjusting] = useState(false) + const adjustTimeout = useRef(-1) + + useEffect(() => { + isZoomedRef.current = zoomState.isZoomed + }, [zoomState.isZoomed]) + + const beginAdjust = useCallback(() => { + setIsAdjusting(true) + window.clearTimeout(adjustTimeout.current) + adjustTimeout.current = window.setTimeout(() => setIsAdjusting(false), 120) + }, []) + + const beginAdjustRef = useRef(beginAdjust) + + useEffect(() => { + beginAdjustRef.current = beginAdjust + }, [beginAdjust]) + + useEffect(() => () => window.clearTimeout(adjustTimeout.current), []) + + const resetZoom = useCallback(() => { + setZoomState(INITIAL_ZOOM_STATE) + setIsDragging(false) + }, []) + + const [prevIndex, setPrevIndex] = useState(currentIndex) + const [prevEnabled, setPrevEnabled] = useState(enabled) + + // Reset during render rather than from an effect: the new slide is then painted at scale + // 1 in the same commit instead of flashing the previous slide's zoom for one frame. + // + // Turning zoom off has to clear the state as well – the image loses its handlers, but a + // lingering `isZoomed` keeps the carousel's `watchDrag` guard rejecting drags, so the + // lightbox would silently stop being swipeable. + if (currentIndex !== prevIndex || enabled !== prevEnabled) { + setPrevIndex(currentIndex) + setPrevEnabled(enabled) + + if (currentIndex !== prevIndex || !enabled) { + setZoomState(INITIAL_ZOOM_STATE) + setIsDragging(false) + } + } + + useEffect(() => { + if (!enabled) { + lastPinchDistance.current = null + didDrag.current = false + } + }, [enabled]) + + const toggleZoom = useCallback(() => { + if (!enabled) { + return + } + setZoomState((prev) => + prev.isZoomed + ? INITIAL_ZOOM_STATE + : { + scale: Math.min(2, maxScale), + translateX: 0, + translateY: 0, + isZoomed: true, + }, + ) + }, [enabled, maxScale]) + + const zoomAtPoint = useCallback( + (deltaScale: number, clientX?: number, clientY?: number) => { + setZoomState((prev) => { + const nextScale = Math.max( + 1, + Math.min(maxScale, prev.scale + deltaScale), + ) + if (nextScale === 1) { + return INITIAL_ZOOM_STATE + } + + const image = imageRef.current + let translateX = prev.translateX + let translateY = prev.translateY + + if (image && clientX !== undefined && clientY !== undefined) { + const rect = image.getBoundingClientRect() + const scaleRatio = nextScale / prev.scale + const offsetX = + clientX - (rect.left + rect.width / 2) + prev.translateX + const offsetY = + clientY - (rect.top + rect.height / 2) + prev.translateY + translateX = offsetX * (1 - scaleRatio) + prev.translateX * scaleRatio + translateY = offsetY * (1 - scaleRatio) + prev.translateY * scaleRatio + } + + return clampTranslate( + { scale: nextScale, translateX, translateY, isZoomed: true }, + image, + ) + }) + }, + [maxScale], + ) + + const panZoom = useCallback((deltaX: number, deltaY: number) => { + if (!isZoomedRef.current) { + return false + } + setZoomState((prev) => + clampTranslate( + { + ...prev, + translateX: prev.translateX + deltaX, + translateY: prev.translateY + deltaY, + }, + imageRef.current, + ), + ) + return true + }, []) + + const zoomAtPointRef = useRef(zoomAtPoint) + + useEffect(() => { + zoomAtPointRef.current = zoomAtPoint + }, [zoomAtPoint]) + + const handleNativeWheel = useRef((event: WheelEvent) => { + // Horizontal trackpad gestures report deltaY === 0 – zooming on those would both + // hijack the gesture and pick a direction at random. + if (event.deltaY === 0) { + return + } + + event.preventDefault() + beginAdjustRef.current() + zoomAtPointRef.current( + event.deltaY > 0 ? -0.2 : 0.2, + event.clientX, + event.clientY, + ) + }) + + const setImageRef = useCallback((node: HTMLImageElement | null) => { + imageRef.current?.removeEventListener('wheel', handleNativeWheel.current) + imageRef.current = node + node?.addEventListener('wheel', handleNativeWheel.current, { + passive: false, + }) + }, []) + + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + lastPointerType.current = event.pointerType + + if (!zoomState.isZoomed || !enabled) { + return + } + setIsDragging(true) + didDrag.current = false + dragStart.current = { x: event.clientX, y: event.clientY } + translateStart.current = { + x: zoomState.translateX, + y: zoomState.translateY, + } + const target = event.target as HTMLElement + if (typeof target?.setPointerCapture === 'function') { + target.setPointerCapture(event.pointerId) + } + }, + [zoomState.isZoomed, zoomState.translateX, zoomState.translateY, enabled], + ) + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (!isDragging || !enabled) { + return + } + const dx = event.clientX - dragStart.current.x + const dy = event.clientY - dragStart.current.y + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { + didDrag.current = true + } + setZoomState((prev) => + clampTranslate( + { + ...prev, + translateX: translateStart.current.x + dx, + translateY: translateStart.current.y + dy, + }, + imageRef.current, + ), + ) + }, + [isDragging, enabled], + ) + + const handlePointerUp = useCallback(() => { + setIsDragging(false) + }, []) + + const handlePointerCancel = useCallback(() => { + setIsDragging(false) + didDrag.current = false + lastPinchDistance.current = null + }, []) + + const handleTouchMove = useCallback( + (event: React.TouchEvent) => { + if (!enabled || event.touches.length !== 2) { + lastPinchDistance.current = null + return + } + const touch1 = event.touches[0] + const touch2 = event.touches[1] + const distance = Math.hypot( + touch1.clientX - touch2.clientX, + touch1.clientY - touch2.clientY, + ) + + if (lastPinchDistance.current !== null) { + beginAdjust() + const delta = (distance - lastPinchDistance.current) * 0.01 + zoomAtPoint( + delta, + (touch1.clientX + touch2.clientX) / 2, + (touch1.clientY + touch2.clientY) / 2, + ) + } + lastPinchDistance.current = distance + }, + [enabled, zoomAtPoint, beginAdjust], + ) + + const handleTouchEnd = useCallback(() => { + lastPinchDistance.current = null + }, []) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + if (!enabled || event.detail > 1) { + return + } + if (didDrag.current) { + didDrag.current = false + return + } + + // Touch taps are handled by the carousel (swipe) and by double-tap to zoom – + // reacting to the synthetic click as well would fight with both. This checks the + // input that produced the click rather than whether the device supports touch, so + // a mouse still zooms on a touchscreen laptop. The recorded type is consumed here + // so a later click without a preceding pointerdown is not treated as a touch. + const pointerType = lastPointerType.current + lastPointerType.current = 'mouse' + lastClickPointerType.current = pointerType + + if (pointerType === 'touch') { + return + } + + toggleZoom() + }, + [enabled, toggleZoom], + ) + + const handleDoubleClick = useCallback(() => { + if (!enabled || lastClickPointerType.current !== 'touch') { + return + } + + toggleZoom() + }, [enabled, toggleZoom]) + + const getImageProps = useCallback( + () => ({ + ref: setImageRef, + style: { + transform: `scale(${zoomState.scale}) translate(${zoomState.translateX / zoomState.scale}px, ${zoomState.translateY / zoomState.scale}px)`, + }, + 'data-zoom-enabled': enabled || undefined, + 'data-zoomed': zoomState.isZoomed || undefined, + 'data-dragging': isDragging || isAdjusting || undefined, + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + onPointerCancel: handlePointerCancel, + onLostPointerCapture: handlePointerUp, + onClick: handleClick, + onDoubleClick: handleDoubleClick, + onTouchMove: handleTouchMove, + onTouchEnd: handleTouchEnd, + }), + [ + zoomState, + isDragging, + isAdjusting, + enabled, + setImageRef, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handlePointerCancel, + handleClick, + handleDoubleClick, + handleTouchMove, + handleTouchEnd, + ], + ) + + // Both objects are memoized: the lightbox context is built from them, and returning fresh + // objects on every render would make that context change identity on every root render, + // re-rendering every slide, thumbnail and custom render function along with it. + const publicZoomState = useMemo( + () => ({ scale: zoomState.scale, isZoomed: zoomState.isZoomed }), + [zoomState.scale, zoomState.isZoomed], + ) + + return useMemo( + () => ({ + zoomState: publicZoomState, + toggleZoom, + resetZoom, + panZoom, + getImageProps, + }), + [publicZoomState, toggleZoom, resetZoom, panZoom, getImageProps], + ) +} diff --git a/web2/src/Component/Recordings/RecordingRenamePopover.tsx b/web2/src/Component/Recordings/RecordingRenamePopover.tsx new file mode 100644 index 0000000..47addd7 --- /dev/null +++ b/web2/src/Component/Recordings/RecordingRenamePopover.tsx @@ -0,0 +1,138 @@ +import { FiEdit } from 'react-icons/fi' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useDisclosure, useViewportSize } from '@mantine/hooks' +import { + Button, + Group, + type MantineStyleProp, + Popover, + Stack, + Text, + TextInput, +} from '@mantine/core' +import { Recording } from '../../types' +import { authFetch, API_BASE } from '../../main' +import { formatTime, formatTimestamp } from './RecordingsGrid' + +export default function RecordingRenamePopover(props: { + recordings: Map + recording: Recording & { page: number; index: number } + onNicknameChange?: ( + recording: Recording & { page: number; index: number }, + nickname: string, + ) => void + styles?: { button: MantineStyleProp } +}) { + const [open, { set: setOpen }] = useDisclosure(false) + const [nickname, setNickname] = useState(props.recording.nickname || '') + const [nicknameInputValue, setNicknameInputValue] = useState( + props.recording.nickname || '', + ) + const nicknameInputRef = useRef(null) + const { width } = useViewportSize() + + useEffect(() => { + setNickname(props.recording.nickname || '') + setNicknameInputValue(props.recording.nickname || '') + }, [props.recording]) + + const saveNickname = useCallback(async () => { + const oldNickname = nickname + setNickname(nicknameInputValue) + authFetch( + `${API_BASE}/api/recordings/${props.recording.streamId}/${encodeURIComponent(props.recording.filename)}/nickname`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nickname: nicknameInputValue }), + }, + ).then((res) => { + if (!res.ok) { + console.error('Failed to save nickname:', res.statusText) + return setNickname(oldNickname) // Reset on error + } + + props.recording.nickname = nicknameInputValue + const streamRecordings = props.recordings.get(props.recording.streamId)! + streamRecordings[props.recording.page - 1][props.recording.index] = + props.recording + props.recordings.set(props.recording.streamId, streamRecordings) + }) + + props.onNicknameChange?.(props.recording, nicknameInputValue.trim()) + }, [nicknameInputValue]) + + const cancelNicknameChange = () => { + setOpen(false) + setNicknameInputValue(props.recording.nickname || '') + } + + return ( + setOpen(false)} + overlayProps={{ zIndex: 10000, blur: '8px' }} + zIndex={10001} + > + + + + + + Recording details + + + Date and time:{' '} + + {formatTimestamp(props.recording.filename)} + + + + Duration:{' '} + + {formatTime(props.recording.duration)} + + + setNicknameInputValue(event.currentTarget.value) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + saveNickname() + } + }} + /> + + + {nicknameInputValue !== nickname && ( + + )} + + + + + ) +} diff --git a/web2/src/Component/Recordings/RecordingsGrid.module.css b/web2/src/Component/Recordings/RecordingsGrid.module.css new file mode 100644 index 0000000..b584280 --- /dev/null +++ b/web2/src/Component/Recordings/RecordingsGrid.module.css @@ -0,0 +1,75 @@ +.thumbnailFrame { + position: relative; + aspect-ratio: '16 / 9'; + margin-bottom: var(--mantine-spacing-sm); + overflow: hidden; + border-radius: var(--mantine-radius-md); + background: var(--mantine-color-dark-6); +} + +.recordingDurationBadge { + position: absolute; + bottom: 10; + right: 10; + background: rgba(0, 0, 0, 0.7); + font-size: 0.8em; + font-weight: 700; + padding: 0.25em 0.55em; + border-radius: 999px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + z-index: 5; +} + +.videoFrame { + position: relative; + width: 60%; + margin: 0 auto; + transform: translateY(-30px); +} + +.inlineFullscreenFrame { + position: absolute; + top: 0; + left: 0; + inset: 0; + z-index: 1003; + height: 80vh; + aspect-ratio: '9 / 16'; + background: var(--mantine-color-black); + isolation: isolate; + overflow: hidden; +} + +.inlineFullscreenVideo { + width: 100%; + height: 100%; +} + +.inlineFullscreenVideo :global(video) { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; +} + +.inlineFullscreenControls { + position: absolute; + inset: auto 0 0; + z-index: 1; + transform: none; +} + +.recording:hover { + cursor: pointer; +} + +@media (orientation: portrait) { + .inlineFullscreenVideo :global(video) { + top: 50%; + left: 50%; + width: 80vh; + height: 80vw !important; + transform: translate(-50%, -50%) rotate(90deg); + } +} diff --git a/web2/src/Component/Recordings/RecordingsGrid.tsx b/web2/src/Component/Recordings/RecordingsGrid.tsx new file mode 100644 index 0000000..21c849b --- /dev/null +++ b/web2/src/Component/Recordings/RecordingsGrid.tsx @@ -0,0 +1,553 @@ +import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { IconArrowsMaximize, IconArrowsMinimize } from '@tabler/icons-react' +import { useDisclosure, useMap, useViewportSize } from '@mantine/hooks' +import { + Paper, + SimpleGrid, + Text, + Image, + LoadingOverlay, + Center, + ActionIcon, + Grid, +} from '@mantine/core' +import { useVideo, Video } from '@gfazioli/mantine-video' +import type { CollapsedLightboxSlideData } from '../Lightbox/CollapsedLightbox' +import { Recording, Stream } from '../../types' +import { API_BASE, authFetch, fetchWithRetry } from '../../main' +import { onRecordingDeleted } from '../../event-listeners' +import classes from './RecordingsGrid.module.css' +const CollapsedLightbox = lazy(() => import('../Lightbox/CollapsedLightbox')) + +export type SignedThumbnailUrl = { + filename: string + url: string + expiresAt: number +} + +export type SignedUrlsCache = Map< + string, + Map +> + +export default function RecordingsGrid(props: { + currentPage: (Recording & { page: number; index: number })[] + recordings: Map + recordingsCount: Map + pageLoading: boolean + activeStream?: Stream + signedUrlsCache: SignedUrlsCache + signedUrlRequests: Map> +}) { + const signedUrlsMap = props.signedUrlsCache + const loadedThumbnailMap = useMap() + + const { width } = useViewportSize() + const { canFullscreen } = useVideo() + const [loading, setLoading] = useState(false) + // eslint-disable-next-line func-call-spacing + const [activeRecording, setRecording] = useState< + (Recording & { page: number; index: number }) | null + >(null) + const videoRef = useRef(null) + const [videoElement, setVideoElement] = useState( + null, + ) + const [ + inlineFullscreen, + { set: setInlineFullscreen, toggle: toggleInlineFullscreen }, + ] = useDisclosure(false) + const recordingRequestId = useRef(0) + + const setVideoContainer = useCallback((element: HTMLDivElement | null) => { + videoRef.current = element + setVideoElement(element?.getElementsByTagName('video')[0] ?? null) + }, []) + + const fetchRecordingUrl = useCallback( + async (currentUrl?: string, force = false) => { + if (!activeRecording) return + + const parsedUrl = currentUrl ? URL.parse(currentUrl) : null + const expires = Number(parsedUrl?.searchParams.get('expires')) + const isFresh = + parsedUrl && + Number.isFinite(expires) && + expires * 1000 - 5_000 >= Date.now() + + if (!force && isFresh) return currentUrl + + const requestId = ++recordingRequestId.current + const url = `${API_BASE}/api/signed-url/${activeRecording.streamId}?type=video&filename=${activeRecording.filename}` + + try { + const response = await fetchWithRetry(() => authFetch(url)) + const signedUrl = `${API_BASE}${(await response.json()).url}` + + if (requestId !== recordingRequestId.current) return + return signedUrl + } catch { + if (requestId !== recordingRequestId.current) return + console.error('Failed to fetch signed stream URL') + } + }, + [activeRecording], + ) + + const currentLightboxIndex = useMemo( + () => + props.currentPage.findIndex((recording) => { + if (activeRecording) { + return ( + recording.filename === activeRecording.filename && + recording.streamId === activeRecording.streamId + ) + } + }), + [props.currentPage, activeRecording], + ) + const [lightboxOpen, { set: setLightboxOpen }] = useDisclosure(false) + const [loadLightbox, { set: setLoadLightbox }] = useDisclosure(false) + const lightboxSlides = useMemo( + () => + props.currentPage.map((recording) => ({ + type: 'custom', + recording, + autoPlay: true, + render: ({ active }) => ( +
+ {active && ( + <> + +
+ + {/* + {`${recording.nickname ? `${recording.nickname} (` : ''}${formatTime(recording.duration)}${recording.nickname ? ')' : ''}`} + */} + +
+ + )} +
+ ), + renderThumb: () => ( +
+ {signedUrlsMap.has(recording.streamId) && + signedUrlsMap.get(recording.streamId)!.has(recording.page) && + recording.index < + signedUrlsMap.get(recording.streamId)!.get(recording.page)! + .length ? ( + <> + + {formatTime(recording.duration)} + + + + ) : ( + + {recording.filename} + + )} +
+ ), + })), + [canFullscreen, inlineFullscreen, props.currentPage, loading, width], + ) + + useEffect(() => { + if (!lightboxOpen) setInlineFullscreen(false) + }, [activeRecording, lightboxOpen]) + + useEffect(() => { + ;(async function getSignedUrls() { + if (!props.activeStream || props.pageLoading) return + + const page = props.currentPage[0]?.page + if ( + !Number.isInteger(page) || + props.currentPage.some( + (recording) => recording.streamId !== props.activeStream!.id, + ) + ) { + return + } + + const filenames = props.currentPage.map((recording) => + recording.filename.replace('.mp4', '.jpg'), + ) + let streamSignedUrls = signedUrlsMap.get(props.activeStream.id) + if (!streamSignedUrls) { + streamSignedUrls = new Map() + signedUrlsMap.set(props.activeStream.id, streamSignedUrls) + } + const cachedUrls = streamSignedUrls.get(page) + const cacheIsFresh = + cachedUrls?.length === filenames.length && + cachedUrls.every( + (signedUrl, index) => + signedUrl?.filename === filenames[index] && + signedUrl.expiresAt - 10 >= Date.now() / 1000, + ) + + if (cacheIsFresh) return + + const requestKey = `${props.activeStream.id}:${page}:${filenames.join('\0')}` + const pendingRequest = props.signedUrlRequests.get(requestKey) + if (pendingRequest) { + await pendingRequest + return + } + + const request = (async () => { + const res = await authFetch( + `${API_BASE}/api/signed-urls/${props.activeStream!.id}?filenames=${filenames.join(',')}&type=thumbnail`, + ) + if (!res.ok) { + setTimeout(() => getSignedUrls(), 1000) // Try again every second + return console.error( + 'Failed to load recordings: ' + (await res.text()), + ) + } + const signedUrls = (await res.json()) as { + filename: string + url: string + expiresAt: number + }[] + + streamSignedUrls.set( + page, + signedUrls.map((signedUrl) => { + if (!signedUrl.url || !signedUrl.expiresAt || !signedUrl.filename) { + console.error(`Invalid URL signing output: ${signedUrl}`) + return null + } + + return { + filename: signedUrl.filename, + url: signedUrl.url, + expiresAt: signedUrl.expiresAt, + } + }), + ) + })() + + props.signedUrlRequests.set(requestKey, request) + try { + await request + } finally { + if (props.signedUrlRequests.get(requestKey) === request) { + props.signedUrlRequests.delete(requestKey) + } + } + })() + }, [ + props.currentPage, + props.pageLoading, + props.signedUrlRequests, + props.signedUrlsCache, + props.activeStream, + ]) + + useEffect(() => { + if (!lightboxOpen || !activeRecording) return + + if (!videoElement) return + + let cancelled = false + let refreshTimer: number | undefined + let onVideoLoad: (() => void) | undefined + + const loadRecording = async () => { + if (!videoElement) return + setLoading(true) + const src = await fetchRecordingUrl(videoElement.src || undefined) + if (cancelled || !src) { + if (!cancelled) { + setLoading(false) + console.error('Failed to fetch recording source') + } + return + } + + const expires = Number(URL.parse(src)?.searchParams.get('expires')) + onVideoLoad = () => setLoading(false) + videoElement.addEventListener('loadeddata', onVideoLoad) + videoElement.src = src + videoElement.load() + + if (Number.isFinite(expires)) { + refreshTimer = window.setTimeout( + () => loadRecording(), + Math.max(0, expires * 1000 - Date.now() - 5_000), + ) + } + } + + loadRecording() + + return () => { + cancelled = true + if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) + if (videoElement) { + if (onVideoLoad) { + videoElement.removeEventListener('loadeddata', onVideoLoad) + } + videoElement.removeAttribute('src') + videoElement.load() + } + setLoading(false) + } + }, [activeRecording, fetchRecordingUrl, lightboxOpen, videoElement]) + + return ( + + + {loadLightbox && ( + setLightboxOpen(false)} + slides={lightboxSlides} + currentIndex={currentLightboxIndex} + onIndexChange={(index) => setRecording(props.currentPage[index])} + withThumbnails + withDownload + onRecordingDeleted={() => + onRecordingDeleted( + activeRecording!, + props.recordings, + setLoading, + setRecording, + setLightboxOpen, + ) + } + toggleInlineFullscreen={toggleInlineFullscreen} + recordingsCount={props.recordingsCount} + recordings={props.recordings} + activeRecording={activeRecording} + currentSrc={videoElement?.src || ''} + closeOnSwipeDown={!(inlineFullscreen && width < 500)} + withFullscreen={canFullscreen} + closeOnClickOutside={false} + emblaOptions={{ watchDrag: false }} + /> + )} + {props.currentPage?.map((recording, index) => ( + { + if (!loadLightbox) setLoadLightbox(true) + setRecording(recording) + setTimeout(() => setLightboxOpen(true), loadLightbox ? 0 : 50) // Try to let lightbox code load + }} + > +
+ {signedUrlsMap.has(recording.streamId) && + signedUrlsMap.get(recording.streamId)!.has(recording.page) && + recording.index < + signedUrlsMap.get(recording.streamId)!.get(recording.page)! + .length && ( + { + loadedThumbnailMap.set( + `${recording.streamId}-${recording.filename}`, + true, + ) + }} + /> + )} + + {formatTime(recording.duration)} + +
+ + + {recording.nickname} + + + { + if (e.key === 'Enter') { + if (!loadLightbox) setLoadLightbox(true) + setRecording(recording) + setTimeout(() => setLightboxOpen(true), loadLightbox ? 0 : 50) // Try to let lightbox code load + } + }} + tabIndex={0} + style={{ textAlign: 'center' }} + c="gray.6" + > + {formatTimestamp(recording.filename)} + +
+ ))} +
+ ) +} + +function RecordingInfo(props: { recording: Recording; loading: boolean }) { + const { width } = useViewportSize() + + return ( + width <= 600 && + !props.loading && ( + + {props.recording.nickname ? ( + + {props.recording.nickname} —  + + ) : null} + + + {formatTime(props.recording.duration)} —  + + + + {formatTimestamp(props.recording.filename)} + + + ) + ) +} + +export function formatTime(sec: number) { + if (!isFinite(sec)) return '0:00' + const m = Math.floor(sec / 60) + const s = Math.floor(sec % 60) + return `${m}m ${s.toString().padStart(2, '0')}s` +} + +export function formatTimestamp(filename: string) { + const match = filename.match(/motion_(.+)\.mp4/) + if (!match) return filename + const iso = match[1].replace( + /T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, + (_m, h, m2, s, ms) => `T${h}:${m2}:${s}.${ms}Z`, + ) + const date = new Date(iso) + return isNaN(date.getTime()) ? match[1] : date.toLocaleString() +} diff --git a/web2/src/Component/Recordings/RecordingsPages.tsx b/web2/src/Component/Recordings/RecordingsPages.tsx new file mode 100644 index 0000000..c7ffbb8 --- /dev/null +++ b/web2/src/Component/Recordings/RecordingsPages.tsx @@ -0,0 +1,320 @@ +import { useCallback, useEffect, useState } from 'react' +import { useLocalStorage, useMap } from '@mantine/hooks' +import { + Box, + Code, + Combobox, + Input, + InputBase, + Pagination, + Title, + useCombobox, + useMantineColorScheme, + Text, + useMantineTheme, +} from '@mantine/core' +import { Recording, type Stream } from '../../types' +import { API_BASE, authFetch } from '../../main' +import RecordingsGrid, { SignedThumbnailUrl } from './RecordingsGrid' + +export default function RecordingsPages(props: { + streams: Map +}) { + const [loading, setLoading] = useState(false) + const { colorScheme } = useMantineColorScheme() + const { colors } = useMantineTheme() + + const totalRecordings = useMap() + const signedUrlsCache = useMap< + string, + // eslint-disable-next-line func-call-spacing + Map + >() + const signedUrlRequests = useMap>() + + const recordings = useMap< + // eslint-disable-next-line func-call-spacing + string, + (Recording & { page: number; index: number })[][] + >() + const [activePage, setPage] = useState(1) + const [lastFailedPage, setLastFailedPage] = useState(0) + const [activeStream, setStream] = useState(null) + const [selectedStream, saveSelectedStream] = useLocalStorage({ + key: 'activeStream', + }) + + const streamCombobox = useCombobox({ + onDropdownClose: () => streamCombobox.resetSelectedOption(), + }) + const streamComboboxOptions = props.streams + .entries() + .toArray() + .map((stream, index) => ( + + + + {stream[1].nickname} + + + + )) + + const getItems = useCallback(() => { + if (!activeStream) return <>Select a stream to view recordings. + + const streamRecordings = recordings.get(activeStream.id) + const activePageRecordings = streamRecordings?.[activePage - 1] + const recordingsToRender = activePageRecordings?.length + ? activePageRecordings + : streamRecordings?.find((page) => page.length > 0) + + if (!recordingsToRender) { + return <>No recordings to show. + } + + return ( + + ) + }, [activePage, activeStream, recordings, loading]) + + useEffect(() => { + // Check if there are no recordings + const streamRecordings = activeStream + ? recordings.get(activeStream.id) + : undefined + const total = activeStream + ? totalRecordings.get(activeStream!.id) + : undefined + if (total === 0) { + if ( + activeStream && + (!streamRecordings?.length || streamRecordings[0].length !== 0) + ) { + recordings.set(activeStream!.id, [[]]) + } + return + } + + if (lastFailedPage !== 0 && activePage === lastFailedPage) return + if (!activeStream && props.streams.size) { + let stream = selectedStream + ? (props.streams.get(selectedStream) ?? + props.streams.values().toArray()[0]) + : props.streams.values().toArray()[0] + setStream(stream) + saveSelectedStream(stream.id) + } + ;(async () => { + if (activeStream) { + const currentRecordings = recordings.get(activeStream.id) + if (currentRecordings && currentRecordings.length >= activePage) { + const currentPage = currentRecordings[activePage - 1] + // The page is already cached + if (currentPage.length !== 0) return + } + + console.log( + `Fetching page ${activePage} of stream: ${activeStream?.id} (${activeStream.nickname})`, + ) + setLoading(true) + const res = await authFetch( + `${API_BASE}/api/recordings/${activeStream.id}/${activePage}`, + ) + if (!res.ok) { + console.error(`Error loading recordings: ${await res.text()}`) + setLastFailedPage(activePage) + setLoading(false) + return + } + + setLastFailedPage(0) + + const page = (await res.json()) as { + total: number + recordings: (Recording & { motionTimestamps: string })[] + deletedRecordings: string[] + } + + if (page.total === undefined || !Array.isArray(page.recordings)) { + console.error(`Error loading recordings: ${JSON.stringify(page)}`) + setLastFailedPage(activePage) + totalRecordings.set(activeStream.id, page.total || 0) + setLoading(false) + return + } + + const newRecordings = page.recordings.map((rec, index) => ({ + ...rec, + motionTimestamps: JSON.parse(rec.motionTimestamps) as number[], + page: activePage, + index, + })) + + totalRecordings.set(activeStream.id, page.total) + + // Set first page and initialize the rest as empty if we just started + if (!currentRecordings) { + recordings.set(activeStream.id, [ + newRecordings, + ...(() => { + const blankPages: [][] = [] + + // 20 items per page + for (let i = 0; i < Math.ceil(page.total / 20); i++) { + blankPages.push([]) + } + + return blankPages + })(), + ]) + } else { + // Create empty pages first + if (currentRecordings.length < activePage) { + recordings.set(activeStream.id, [ + ...currentRecordings, + ...(() => { + const blankPages: [][] = [] + + for (let i = currentRecordings.length; i < activePage; i++) { + blankPages.push([]) + } + + return blankPages + })(), + ]) + } + + currentRecordings[activePage - 1] = newRecordings + recordings.set(activeStream.id, currentRecordings) + } + + setLoading(false) + } + })() + }, [props.streams, activeStream, recordings, activePage, totalRecordings]) + + return ( + activeStream && ( + <> + { + const stream = props.streams.get(streamId)! + setPage(1) + setStream(stream) + saveSelectedStream(stream.id) + streamCombobox.closeDropdown() + }} + > + + { + streamCombobox.clickSelectedOption() + streamCombobox.closeDropdown() + }} + component="button" + type="button" + pointer + rightSection={} + rightSectionPointerEvents="none" + onClick={() => streamCombobox.toggleDropdown()} + size="md" + styles={{ + input: { + height: 'auto', + minHeight: 'var(--input-height-md)', + whiteSpace: 'normal', + textAlign: 'left', + }, + }} + > + + {activeStream ? ( + <> + <Code + color={colorScheme === 'light' ? 'blue.6' : 'blue.9'} + style={{ fontSize: '1em' }} + > + <Text + span + size="1em" + style={{ + color: + colorScheme === 'light' + ? colors.gray[0] + : colors.gray[4], + }} + > + {activeStream.nickname} + </Text> + </Code>{' '} + recordings + </> + ) : ( + <Input.Placeholder>Loading recordings...</Input.Placeholder> + )} + + + + + + {streamComboboxOptions} + + + + + + {getItems()} + + + + + ) + ) +} diff --git a/web2/src/Component/Sessions/SessionsLogoutButton.module.css b/web2/src/Component/Sessions/SessionsLogoutButton.module.css new file mode 100644 index 0000000..55a5172 --- /dev/null +++ b/web2/src/Component/Sessions/SessionsLogoutButton.module.css @@ -0,0 +1,11 @@ +.button { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.menuControl { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border: 0; + border-left: 1px solid var(--mantine-color-body); +} diff --git a/web2/src/Component/Sessions/SessionsLogoutButton.tsx b/web2/src/Component/Sessions/SessionsLogoutButton.tsx new file mode 100644 index 0000000..e05aadf --- /dev/null +++ b/web2/src/Component/Sessions/SessionsLogoutButton.tsx @@ -0,0 +1,51 @@ +import { FiChevronDown, FiLogOut, FiUsers } from 'react-icons/fi' +import { ActionIcon, Button, Group, Menu, useMantineTheme } from '@mantine/core' +import classes from './SessionsLogoutButton.module.css' + +export function SessionsLogoutButton( + props?: React.ComponentPropsWithoutRef & { + logout: (skipBroadcast?: boolean) => Promise + }, +) { + const theme = useMantineTheme() + + return ( + + + + + + + + + + props?.logout()} + leftSection={} + > + Logout + + + + + ) +} diff --git a/web2/src/Component/Streams/StreamAddButton/StreamAddButton.module.css b/web2/src/Component/Streams/StreamAddButton/StreamAddButton.module.css new file mode 100644 index 0000000..55a5172 --- /dev/null +++ b/web2/src/Component/Streams/StreamAddButton/StreamAddButton.module.css @@ -0,0 +1,11 @@ +.button { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.menuControl { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border: 0; + border-left: 1px solid var(--mantine-color-body); +} diff --git a/web2/src/Component/Streams/StreamAddButton/StreamAddButton.tsx b/web2/src/Component/Streams/StreamAddButton/StreamAddButton.tsx new file mode 100644 index 0000000..4253bcb --- /dev/null +++ b/web2/src/Component/Streams/StreamAddButton/StreamAddButton.tsx @@ -0,0 +1,82 @@ +import { FiChevronDown, FiPlus } from 'react-icons/fi' +import { ActionIcon, Button, Group, Menu } from '@mantine/core' +import CreateEditStreamPopover from '../../CreateStream/CreateEditStreamPopover' +import { Stream } from '../../../types' +import classes from './StreamAddButton.module.css' + +export default function StreamAddButton(props: { + color?: string + streams: Map + setLayouts: ( + val: + | { + id: number + splitterStreams: string[] + }[] + | (( + prevState: { + id: number + splitterStreams: string[] + }[], + ) => { + id: number + splitterStreams: string[] + }[]), + ) => void + setStream: (streamId: string) => void | Promise +}) { + return ( + + + + + + + + + + + + Cameras + {props.streams + .entries() + .toArray() + .map((stream, index) => ( + props.setStream(stream[0])} + > + {stream[1].nickname ? stream[1].nickname : `Camera ${index}`} + + ))} + + + + ) +} diff --git a/web2/src/Component/Streams/StreamEditMenu.tsx b/web2/src/Component/Streams/StreamEditMenu.tsx new file mode 100644 index 0000000..fe390a3 --- /dev/null +++ b/web2/src/Component/Streams/StreamEditMenu.tsx @@ -0,0 +1,78 @@ +import { FiSettings, FiTrash, FiX } from 'react-icons/fi' +import { useState } from 'react' +import { Group, Menu } from '@mantine/core' +import CreateEditStreamPopover from '../CreateStream/CreateEditStreamPopover' +import { Stream } from '../../types' + +export default function StreamEditMenu(props: { + ref?: React.Ref + children: React.ReactNode + streams: Map + setLayouts: ( + val: + | { + id: number + splitterStreams: string[] + }[] + | (( + prevState: { + id: number + splitterStreams: string[] + }[], + ) => { + id: number + splitterStreams: string[] + }[]), + ) => void + stream: Stream + setStream(streamId: string): void | Promise +}) { + const [popoverOpen, setPopoverOpen] = useState(false) + + return ( + + + {props.children} + + Layout + props.setStream('')} + leftSection={} + > + Remove + + + + + Camera + + }> + Settings + + + }> + Delete + + + + + ) +} diff --git a/web2/src/Component/Streams/StreamVideo.module.css b/web2/src/Component/Streams/StreamVideo.module.css new file mode 100644 index 0000000..e69de29 diff --git a/web2/src/Component/Streams/StreamVideo.tsx b/web2/src/Component/Streams/StreamVideo.tsx new file mode 100644 index 0000000..3e0ac9f --- /dev/null +++ b/web2/src/Component/Streams/StreamVideo.tsx @@ -0,0 +1,248 @@ +/* eslint-disable import/no-named-as-default-member */ +import { FiChevronDown } from 'react-icons/fi' +import { useCallback, useEffect, useRef, useState } from 'react' +// eslint-disable-next-line import/no-named-as-default +import Hls from 'hls.js' +import { useHover, useViewportSize } from '@mantine/hooks' +import { ActionIcon, LoadingOverlay } from '@mantine/core' +import { Video } from '@gfazioli/mantine-video' +import { type Stream } from '../../types' +import { API_BASE, fetchWithRetry, authFetch } from '../../main' +import StreamEditMenu from './StreamEditMenu' + +export default function StreamVideo(props: { + streams: Map + stream: Stream + setLayouts: ( + val: + | { + id: number + splitterStreams: string[] + }[] + | (( + prevState: { + id: number + splitterStreams: string[] + }[], + ) => { + id: number + splitterStreams: string[] + }[]), + ) => void + getAspectRatio: () => string + removeFromLayout: () => void | Promise + setStream(streamId: string): Promise | void +}) { + const [streamUrl, setStreamUrl] = useState() + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const videoRef = useRef(null) + const { hovered: videoHovered, ref: videoHoveredRef } = + useHover() + const { hovered: popoverHovered, ref: popoverHoveredRef } = + useHover() + const streamRequestId = useRef(0) + const { width } = useViewportSize() + + const fetchStreamUrl = useCallback( + async (currentUrl?: string, force = false) => { + const parsedUrl = currentUrl ? URL.parse(currentUrl) : null + const expires = Number(parsedUrl?.searchParams.get('expires')) + const isFresh = + parsedUrl && + Number.isFinite(expires) && + expires * 1000 - 5_000 >= Date.now() + + if (!force && isFresh) return + + const requestId = ++streamRequestId.current + const url = `${API_BASE}/api/signed-stream-url/${props.stream.id}` + + try { + const response = await fetchWithRetry(() => authFetch(url)) + const signedUrl = `${API_BASE}${(await response.json()).url}` + + if (requestId !== streamRequestId.current) return + setError(null) + setStreamUrl(signedUrl) + } catch { + if (requestId !== streamRequestId.current) return + console.error('Failed to fetch signed stream URL') + setError('Failed to load the stream. Please try again later.') + } + }, + [props.stream.id], + ) + + useEffect(() => { + streamRequestId.current++ + setStreamUrl(undefined) + fetchStreamUrl() + }, [props.stream.id]) + + useEffect(() => { + if (!streamUrl) return + + const expires = Number(URL.parse(streamUrl)?.searchParams.get('expires')) + if (!Number.isFinite(expires)) return + + const refreshTimer = window.setTimeout( + () => fetchStreamUrl(streamUrl, true), + Math.max(0, expires * 1000 - Date.now() - 5_000), + ) + + return () => window.clearTimeout(refreshTimer) + }, [streamUrl, fetchStreamUrl]) + + const loadStream = useCallback(() => { + const video = videoRef.current + if (!video || !streamUrl) return + + const videoElement = video.getElementsByTagName('video')[0] + if (!videoElement) { + setError('Video player is not available.') + return + } + + setLoading(true) + let seekTimeout: number + + if (Hls.isSupported()) { + const hls = new Hls({ + liveSyncDuration: 1, + }) + hls.loadSource(streamUrl) + hls.attachMedia(videoElement) + + seekTimeout = setTimeout(() => { + if (hls.liveSyncPosition !== null) { + const targetTime = hls.liveSyncPosition + console.log(`Setting current time to ${targetTime}`) + videoElement.currentTime = Math.max( + targetTime, + videoElement.currentTime, + ) + } + }, 5000) + + hls.on(Hls.Events.ERROR, (_event, data) => { + console.log('HLS error:', data.type, data.details, data) + + // Check for 403 Forbidden on fragment/network errors + if ( + data.type === Hls.ErrorTypes.NETWORK_ERROR && + data.response && + data.response.code === 403 + ) { + console.warn('HLS 403 Forbidden') + fetchStreamUrl(streamUrl, true) + return + } + }) + + setLoading(false) + return () => hls.destroy() + } else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) { + videoElement.src = streamUrl + setError(null) + } else { + setError('This browser does not support video streaming.') + } + + setLoading(false) + return () => { + videoElement.removeAttribute('src') + videoElement.load() + clearTimeout(seekTimeout) + } + }, [videoRef, streamUrl]) + + useEffect(() => { + if (streamUrl) return loadStream() + }, [streamUrl, loadStream]) + + return ( + <> + {error && ( +
+ {error} +
+ )} + + + + ) +} diff --git a/web2/src/Component/Streams/StreamsGrid.tsx b/web2/src/Component/Streams/StreamsGrid.tsx new file mode 100644 index 0000000..00100b3 --- /dev/null +++ b/web2/src/Component/Streams/StreamsGrid.tsx @@ -0,0 +1,426 @@ +import { lazy, useCallback, useEffect, useRef } from 'react' +import { + type SplitterPaneSize, + useLocalStorage, + type UseSplitterReturnValue, + useViewportSize, +} from '@mantine/hooks' +import { type CSSProperties, Group, Splitter } from '@mantine/core' +import { type Stream } from '../../types' +import StreamVideo from './StreamVideo' +const StreamAddButton = lazy(() => import('./StreamAddButton/StreamAddButton')) + +type StoredSplitterLayout = { + sizes: SplitterPaneSize[] + collapsed: boolean[] + streamId?: string +} + +export default function StreamsGrid(props: { + streams: Map + layout: { id: number; splitterStreams: string[] } + setLayouts: ( + val: + | { + id: number + splitterStreams: string[] + }[] + | (( + prevState: { + id: number + splitterStreams: string[] + }[], + ) => { + id: number + splitterStreams: string[] + }[]), + ) => void + setLayoutStreams: (streams: string[]) => void | Promise + setOnLayoutDeleted: React.Dispatch< + React.SetStateAction<(layoutId: number) => void> + > +}) { + const splitterRef = useRef(null) + const splitter2Ref = useRef(null) + const splitter3Ref = useRef(null) + const splitters = [splitterRef, splitter2Ref, splitter3Ref] + + const { width, height } = useViewportSize() + + // eslint-disable-next-line func-call-spacing + const [storedSplitterLayouts, setStoredSplitterLayouts] = useLocalStorage< + (StoredSplitterLayout[] | null)[] + >({ + key: 'splitterState', + defaultValue: [], + }) + + const onLayoutDeleted = useCallback( + (layoutId: number) => { + setStoredSplitterLayouts((prev) => { + if (!prev || !prev[layoutId]) return prev + + const next = [...prev] + next[layoutId] = null + return next + }) + }, + [setStoredSplitterLayouts], + ) + + const getAspectRatio = useCallback(() => { + if (splitterRef.current?.collapsed.includes(true)) { + const uncollapsedIndex = splitterRef.current.collapsed.findIndex( + (c) => c === false, + ) + if (uncollapsedIndex === -1) return '16 / 9' + if (splitters[uncollapsedIndex + 1].current?.collapsed.includes(true)) { + return '16 / 9' + } else return '16 / 18' + } + return '16 / 9' + }, [splitterRef.current, splitter2Ref.current, splitter3Ref.current]) + + const getMarginLeft = useCallback( + () => + splitterRef.current?.collapsed.includes(true) + ? splitter2Ref.current?.collapsed.includes(true) || + splitter3Ref.current?.collapsed.includes(true) + ? width > 768 + ? '50px' + : undefined + : undefined + : undefined, + [width, splitterRef.current, splitter2Ref.current, splitter3Ref.current], + ) + + const [splitterStyles, setSplitterStyles] = useLocalStorage({ + key: 'splitterStyles', + defaultValue: { + zIndex: 1000, + aspectRatio: getAspectRatio(), + }, + }) + + useEffect(() => { + props.setOnLayoutDeleted(() => onLayoutDeleted) + }, [props.setOnLayoutDeleted]) + + useEffect(() => { + if (props.streams.size === 0) return + + if (!storedSplitterLayouts || !storedSplitterLayouts[props.layout.id]) { + setStoredSplitterLayouts((prev) => { + const next = prev ? [...prev] : [] + while (next.length <= props.layout.id) next.push(null) + next[props.layout.id] ??= [ + { + sizes: [50, 50], + collapsed: [false, false], + }, + { + sizes: [50, 50], + collapsed: [false, false], + }, + { + sizes: [50, 50], + collapsed: [false, false], + }, + ] + return next + }) + return + } + + const layoutState = storedSplitterLayouts[props.layout.id] + + splitters.forEach((splitter, index) => { + if (!splitter.current || !layoutState?.[index]) { + return + } + splitter.current.setSizes(layoutState[index].sizes) + splitter.current.collapsed = layoutState[index].collapsed + }) + }, [ + setStoredSplitterLayouts, + storedSplitterLayouts, + props.layout.id, + props.streams.size, + ]) + + useEffect(() => { + if (props.streams.size === 0 || !storedSplitterLayouts[props.layout.id]) { + return + } + + let previousState: string[] = ['', '', '', ''] + + const saveSplitterState = () => { + const splitter = splitterRef.current + + if (!splitter) { + return + } + + splitters.forEach((splitter, index) => { + if (!splitter.current) return + const state = { + sizes: splitter.current.sizes, + collapsed: splitter.current.collapsed, + } + const serializedState = JSON.stringify(state) + + if (serializedState !== previousState[index]) { + previousState[index] = serializedState + setStoredSplitterLayouts((prev) => { + const newState = prev ? [...prev] : [] + if (!newState[props.layout.id]) newState[props.layout.id] = [] + newState[props.layout.id]![index] = state + + return newState + }) + } + }) + } + + const interval = window.setInterval(saveSplitterState, 500) + + return () => window.clearInterval(interval) + }, [props.layout.id, props.streams.size, storedSplitterLayouts]) + + useEffect(() => { + let cancelled = false + const correctAspectRatio = () => { + if (cancelled) return + + setSplitterStyles((prev) => ({ + ...prev, + aspectRatio: getAspectRatio(), + /* marginLeft: getMarginLeft(), */ + })) + } + + const aspectRatioInterval = setInterval(correctAspectRatio, 100) + + return () => { + clearInterval(aspectRatioInterval) + cancelled = true + } + }, []) + + const splitterStreams = props.layout.splitterStreams + + return ( + + = 768 && + !(height < 450) && + splitterRef.current?.collapsed[1] + ? 'translateX(-59px)' // This offset is due to resizing the remaning splitter to 16:18 + : undefined, + }, + }} + > + + { + const splitter2 = splitter2Ref.current + if (!splitter2) return + if (splitter2.collapsed[0]) return -19 + if (splitter2.collapsed[1]) return 10 + })(), + }, + }} + style={{ + aspectRatio: + splitterRef.current?.collapsed[1] === true + ? getAspectRatio() + : undefined, + }} + > + + {splitterStreams?.[0] && props.streams.get(splitterStreams[0]) ? ( + { + splitterStreams[0] = streamId + props.setLayoutStreams(splitterStreams) + }} + setLayouts={props.setLayouts} + stream={props.streams.get(splitterStreams[0])!} + streams={props.streams} + getAspectRatio={getAspectRatio} + removeFromLayout={() => { + delete splitterStreams[0] + props.setLayoutStreams(splitterStreams) + }} + /> + ) : ( + + { + splitterStreams[0] = streamId + props.setLayoutStreams(splitterStreams) + }} + /> + + )} + + + {splitterStreams?.[1] && props.streams.get(splitterStreams[1]) ? ( + { + delete splitterStreams[1] + props.setLayoutStreams(splitterStreams) + }} + setStream={(streamId) => { + splitterStreams[1] = streamId + props.setLayoutStreams(splitterStreams) + }} + /> + ) : ( + + { + splitterStreams[1] = streamId + props.setLayoutStreams(splitterStreams) + }} + /> + + )} + + + + + { + const splitter3 = splitter3Ref.current + if (!splitter3) return + if (splitter3.collapsed[0]) return -19 + if (splitter3.collapsed[1]) return 10 + })(), + }, + }} + style={{ + aspectRatio: + splitterRef.current?.collapsed[0] === true + ? getAspectRatio() + : undefined, + }} + > + + {splitterStreams?.[2] && props.streams.get(splitterStreams[2]) ? ( + { + delete splitterStreams[2] + props.setLayoutStreams(splitterStreams) + }} + setStream={(streamId) => { + splitterStreams[2] = streamId + props.setLayoutStreams(splitterStreams) + }} + /> + ) : ( + + { + splitterStreams[2] = streamId + props.setLayoutStreams(splitterStreams) + }} + /> + + )} + + + {splitterStreams?.[3] && props.streams.get(splitterStreams[3]) ? ( + { + delete splitterStreams[3] + props.setLayoutStreams(splitterStreams) + }} + setStream={(streamId) => { + splitterStreams[3] = streamId + props.setLayoutStreams(splitterStreams) + }} + /> + ) : ( + + { + splitterStreams[3] = streamId + props.setLayoutStreams(splitterStreams) + }} + /> + + )} + + + + + + ) +} diff --git a/web2/src/Component/ThemeToggle.module.css b/web2/src/Component/ThemeToggle.module.css new file mode 100644 index 0000000..e111fe8 --- /dev/null +++ b/web2/src/Component/ThemeToggle.module.css @@ -0,0 +1,24 @@ +.icon { + width: 22px; + height: 22px; +} + +.dark { + @mixin dark { + display: none; + } + + @mixin light { + display: block; + } +} + +.light { + @mixin light { + display: none; + } + + @mixin dark { + display: block; + } +} diff --git a/web2/src/Component/ThemeToggle.tsx b/web2/src/Component/ThemeToggle.tsx new file mode 100644 index 0000000..b4132d0 --- /dev/null +++ b/web2/src/Component/ThemeToggle.tsx @@ -0,0 +1,47 @@ +import { useEffect } from 'react' +import { IconMoon, IconSun } from '@tabler/icons-react' +import { useLocalStorage } from '@mantine/hooks' +import { + ActionIcon, + Group, + type MantineColorScheme, + useComputedColorScheme, + useMantineColorScheme, +} from '@mantine/core' +import classes from './ThemeToggle.module.css' + +export function ThemeToggle() { + const { setColorScheme } = useMantineColorScheme() + const computedColorScheme = useComputedColorScheme('dark', { + getInitialValueInEffect: true, + }) + const [savedScheme, setSavedScheme] = useLocalStorage({ + key: 'mantine-color-scheme-value', + defaultValue: 'dark', + }) + + useEffect(() => { + if (savedScheme) setColorScheme(savedScheme) + }, [savedScheme]) + + return ( + + { + setSavedScheme(computedColorScheme === 'light' ? 'dark' : 'light') + }} + variant="default" + size="xl" + radius="md" + aria-label="Toggle color scheme" + > + + + + + ) +} diff --git a/web2/src/Layout/FullLayout.tsx b/web2/src/Layout/FullLayout.tsx new file mode 100644 index 0000000..33a82ae --- /dev/null +++ b/web2/src/Layout/FullLayout.tsx @@ -0,0 +1,261 @@ +import { FiChevronRight, FiLayout, FiPlus, FiTrash } from 'react-icons/fi' +import { useEffect, useState } from 'react' +import { + useCounter, + useDisclosure, + useLocalStorage, + useMap, + useViewportSize, +} from '@mantine/hooks' +import { + AppShell, + Burger, + Group, + NavLink, + ScrollArea, + Space, + useMantineColorScheme, + useMantineTheme, +} from '@mantine/core' +import { authFetch } from '../main' +import { ThemeToggle } from '../Component/ThemeToggle' +import StreamsGrid from '../Component/Streams/StreamsGrid' +import { SessionsLogoutButton } from '../Component/Sessions/SessionsLogoutButton' +import RecordingsPages from '../Component/Recordings/RecordingsPages' +import { type Stream } from '../../../source/types/shared' + +export default function FullLayout(props: { + logout: (skipBroadcast?: boolean) => Promise + lastFailedJwt: string | undefined + setFailedJwt: React.Dispatch> +}) { + const [openedMenu, { toggle: toggleMenu }] = useDisclosure() + const [openedLayouts, { toggle: toggleLayouts }] = useDisclosure(true) + const streams = useMap() + const [loadingStreams, setLoadingStreams] = useState(false) + const [streamsLoaded, setStreamsLoaded] = useState(false) + const [ + loadingFailed, + { increment: incrementLoadingFailed, reset: resetLoadingFailed }, + ] = useCounter(0) + const [layouts, setLayouts] = useLocalStorage< + { id: number; splitterStreams: string[] }[] + >({ + key: 'streamLayouts', + defaultValue: [{ id: 0, splitterStreams: [] }], + }) + const [hoveredLayouts, setHoveredLayouts] = useState([]) + const [activeLayout, setActiveLayout] = useLocalStorage({ + key: 'activeLayout', + defaultValue: 0, + }) + // eslint-disable-next-line func-call-spacing + const [onLayoutDeleted, setOnLayoutDeleted] = useState< + (layoutId: number) => void + >(() => { + /* empty */ + }) + const { width } = useViewportSize() + const { colors } = useMantineTheme() + const { colorScheme } = useMantineColorScheme() + + const hasMouse = window.matchMedia('(pointer:fine)').matches + + useEffect(() => { + if (loadingFailed < 3 && !loadingStreams && !streamsLoaded) { + setLoadingStreams(true) + authFetch('/api/streams') + .then((res) => { + if (!res.ok) { + incrementLoadingFailed() + setLoadingStreams(false) + throw new Error(`Failed to fetch streams: ${res.statusText}`) + } + return res.json() as Promise + }) + .then((nextStreams) => { + resetLoadingFailed() + nextStreams.forEach((stream) => streams.set(stream.id, stream)) + setLoadingStreams(false) + setStreamsLoaded(true) + }) + .catch((err) => { + incrementLoadingFailed() + setLoadingStreams(false) + throw new Error(`Failed to fetch streams: ${err}`) + }) + } + }, [streams, loadingStreams, loadingFailed, streamsLoaded]) + + return ( + + + + + Gander + + + + + + + + { + e.preventDefault() + toggleLayouts() + }} + leftSection={} + rightSection={ + + } + /> + + {openedLayouts && + layouts + .map(({ id }) => ( + { + e.preventDefault() + setActiveLayout(id) + toggleMenu() + }} + style={ + id === activeLayout + ? { + backgroundColor: + colorScheme === 'light' + ? colors.gray[2] + : colors.dark[6], + } + : undefined + } + onPointerOver={() => + setHoveredLayouts((prev) => prev.concat(id)) + } + onPointerLeave={() => + setHoveredLayouts((prev) => prev.filter((i) => i !== id)) + } + label={`Layout ${id}`} + rightSection={ + id !== 0 && ( + { + e.preventDefault() + e.stopPropagation() + + setLayouts((prev) => + prev.filter((layout) => layout.id !== id), + ) + if (activeLayout === id) setActiveLayout(0) + if (onLayoutDeleted) onLayoutDeleted(id) + }} + /> + ) + } + /> + )) + .concat( + } + onClick={(e) => { + setLayouts((prev) => { + const nextId = + prev.reduce( + (maxId, layout) => Math.max(maxId, layout.id), + -1, + ) + 1 + return prev.concat({ + id: nextId, + splitterStreams: [], + }) + }) + e.preventDefault() + }} + />, + )} + + + + layout.id === activeLayout)!} + setOnLayoutDeleted={setOnLayoutDeleted} + setLayoutStreams={(streams) => { + layouts[activeLayout].splitterStreams = streams + setLayouts(layouts) + }} + /> + + + + + Aside + + + Gander © 2026 Brandon Bothell. All rights reserved—Privacy is a + right. + + + ) +} diff --git a/web2/src/Layout/LoginLayout.tsx b/web2/src/Layout/LoginLayout.tsx new file mode 100644 index 0000000..3da4fac --- /dev/null +++ b/web2/src/Layout/LoginLayout.tsx @@ -0,0 +1,185 @@ +import { useEffect, useRef, useState } from 'react' +import { useForm } from '@mantine/form' +import { + Alert, + Anchor, + Button, + Center, + Container, + Group, + Paper, + PasswordInput, + Stack, + Text, + TextInput, + Title, +} from '@mantine/core' +import { API_BASE } from '../main' +import { getDeviceFingerprint } from '../device-info' + +type LoginValues = { + username: string + password: string +} + +type LoginLayoutProps = { + onLogin: (token: string, refreshToken: string) => void + setAuthenticated: (authenticated: boolean) => void +} + +export default function LoginLayout({ + onLogin, + setAuthenticated, +}: LoginLayoutProps) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [ctrlHeld, setCtrlHeld] = useState(false) + const [mobileApiKeyVisible, setMobileApiKeyVisible] = useState(false) + const touchTimeoutRef = useRef | null>(null) + const passwordInputRef = useRef(null) + + const form = useForm({ + mode: 'uncontrolled', + initialValues: { username: '', password: '' }, + validate: { + username: (value) => (value.trim() ? null : 'Username is required'), + password: (value) => (value ? null : 'Password is required'), + }, + }) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => setCtrlHeld(event.ctrlKey) + const handleKeyUp = (event: KeyboardEvent) => setCtrlHeld(event.ctrlKey) + + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + if (touchTimeoutRef.current) clearTimeout(touchTimeoutRef.current) + } + }, []) + + useEffect(() => { + if (!mobileApiKeyVisible) return + const hideTimer = setTimeout(() => setMobileApiKeyVisible(false), 10000) + return () => clearTimeout(hideTimer) + }, [mobileApiKeyVisible]) + + const handleTouchStart = () => { + touchTimeoutRef.current = setTimeout( + () => setMobileApiKeyVisible(true), + 2000, + ) + } + + const handleTouchEnd = () => { + if (touchTimeoutRef.current) { + clearTimeout(touchTimeoutRef.current) + touchTimeoutRef.current = null + } + } + + const handleLoginWithApiKey = () => { + const apiKey = form.getValues().password + if (apiKey) { + localStorage.setItem('ak', btoa(apiKey)) + setAuthenticated(true) + } + } + + const handleSubmit = async ({ username, password }: LoginValues) => { + setLoading(true) + setError('') + + try { + const deviceInfo = await getDeviceFingerprint() + const response = await fetch(`${API_BASE}/api/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, deviceInfo }), + }) + const data = await response.json() + + if (data.success) { + localStorage.removeItem('tokenRefreshInProgress') + onLogin(data.token, data.refreshToken) + } else { + setError(data.message ?? 'Login failed') + } + } catch { + setError('Login failed. Please try again.') + } finally { + setLoading(false) + } + } + + return ( +
+ + + +
+ Log in to Gander + + Privacy is your right. + +
+
+ + { + if (event.key === 'Enter' && passwordInputRef.current) { + event.preventDefault() + passwordInputRef.current.focus() + } + }} + /> + + {error && {error}} + + {(ctrlHeld || mobileApiKeyVisible) && ( + + + Login with API Key + + + )} + +
+
+
+
+
+ ) +} diff --git a/web2/src/device-info.ts b/web2/src/device-info.ts new file mode 100644 index 0000000..e29faa0 --- /dev/null +++ b/web2/src/device-info.ts @@ -0,0 +1,90 @@ +export interface TrustedDevice { + ip: string + deviceInfo: DeviceInfo + firstSeen: string + lastSeen: string + loginCount: number +} + +// Update DeviceInfo interface +export interface DeviceInfo { + userAgent: string + platform: string + vendor: string + language: string + timezone: string + screen: string + clientId: string +} + +export interface Session { + ip: string + location?: { + country: string + region: string + city: string + lat: number + lon: number + isp?: string + timezone?: string + postal?: string + // eslint-disable-next-line @typescript-eslint/naming-convention + country_code?: string + asn?: string + } + firstSeen: string + lastSeen: string + isNew?: boolean + isGeolocating?: boolean + geolocated?: boolean +} + +export function getDeviceDisplayName(deviceInfo: DeviceInfo): string { + const { userAgent, platform } = deviceInfo + + // Extract browser info + let browser = 'Unknown Browser' + if (userAgent.includes('Chrome')) browser = 'Chrome' + else if (userAgent.includes('Firefox')) browser = 'Firefox' + else if (userAgent.includes('Safari')) browser = 'Safari' + else if (userAgent.includes('Edge')) browser = 'Edge' + + // Extract OS info + let os = platform + if (userAgent.includes('Windows')) os = 'Windows' + else if (userAgent.includes('Mac')) os = 'macOS' + else if (userAgent.includes('Linux')) os = 'Linux' + else if (userAgent.includes('Android')) os = 'Android' + else if (userAgent.includes('iOS')) os = 'iOS' + + return `${browser} on ${os}` +} + +export async function getDeviceFingerprint(): Promise { + let clientId = localStorage.getItem('clientId') + if (!clientId) { + clientId = crypto.randomUUID + ? crypto.randomUUID() + : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) + localStorage.setItem('clientId', clientId) + } + + return { + userAgent: navigator.userAgent, + platform: navigator.platform ?? 'Unknown', + vendor: navigator.vendor ?? 'Unknown', + language: navigator.language, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + screen: `${screen.width}x${screen.height}`, + clientId, + } +} + +// Helper function to create a unique session identifier +export const getSessionId = (_: string, deviceInfo: DeviceInfo): string => { + return deviceInfo.clientId +} diff --git a/web2/src/event-listeners.ts b/web2/src/event-listeners.ts new file mode 100644 index 0000000..db097ba --- /dev/null +++ b/web2/src/event-listeners.ts @@ -0,0 +1,80 @@ +import { Recording } from './types' +import { authFetch, API_BASE } from './main' + +export function onRecordingDeleted( + recording: Recording & { + page: number + index: number + }, + recordings: Map, + setLoading: React.Dispatch>, + setRecording: React.Dispatch< + React.SetStateAction< + | (Recording & { + page: number + index: number + }) + | null + > + >, + setLightboxOpen: (value: boolean) => void, +) { + setTimeout(async () => { + setLoading(true) + // Refresh the current page and delete cache of future pages + if (recording) { + const streamRecordings = recordings.get(recording.streamId)! + const res = await authFetch( + `${API_BASE}/api/recordings/${recording.streamId}/${recording.page}`, + ) + if (!res.ok) { + console.error(`Error loading recordings: ${await res.text()}`) + setLoading(false) + return + } + const page = (await res.json()) as { + total: number + recordings: (Recording & { motionTimestamps: string })[] + deletedRecordings: string[] + } + if (page.total === undefined || !Array.isArray(page.recordings)) { + console.error('Error loading recordings', page) + setLoading(false) + return + } + const newRecordings = page.recordings.map((rec, index) => ({ + ...rec, + motionTimestamps: JSON.parse(rec.motionTimestamps) as number[], + page: recording.page, + index, + })) + streamRecordings[recording.page - 1] = newRecordings + for (let i = recording.page; i < streamRecordings.length; i++) { + streamRecordings[i] = [] + } + recordings.set(recording.streamId, streamRecordings) + } + const streamRecordings = recording + ? recordings.get(recording.streamId) + : null + if ( + recording && + recording.index < streamRecordings![recording.page - 1].length + ) { + setRecording(streamRecordings![recording.page - 1][recording.index]) + } else if ( + recording && + recording.index - 1 < streamRecordings![recording.page - 1].length + ) { + setRecording(streamRecordings![recording.page - 1][recording.index - 1]) + } else { + console.warn('Next recording not found', recording) + console.log( + `Current page length: ${recording ? streamRecordings![recording.page - 1].length : 'No active recording!'}`, + ) + setLightboxOpen(false) + } + + setLoading(false) + }, 500) // Give time for the recordings to shift pages +} diff --git a/web2/src/main.tsx b/web2/src/main.tsx new file mode 100644 index 0000000..e490cdc --- /dev/null +++ b/web2/src/main.tsx @@ -0,0 +1,220 @@ +import ReactDOM from 'react-dom/client' +import { StrictMode } from 'react' +import App from './App' + +function isIOS() { + return /iPad|iPhone|iPod/.test(navigator.userAgent) +} + +export type CustomWindow = Window & { + getDebugLogs: () => string + clearDebugLogs: () => void +} + +export const API_BASE = '' + +if (isIOS()) { + document.body.classList.add('ios') +} + +// Register service worker +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').then((reg) => { + console.log('Service Worker registered:', reg) + }) +} + +function getTokenOrApiKey(): + | { token: string; type: 'jwt' | 'api' } + | undefined { + const jwt = localStorage.getItem('jwt') + const apiKey = jwt ? null : atob(localStorage.getItem('ak') || '') + return jwt + ? { token: jwt, type: 'jwt' } + : apiKey + ? { token: apiKey, type: 'api' } + : undefined +} + +// Global logout function - will be set by App component +export let globalLogout: (() => Promise) | null = null +export let globalTryRefreshToken: (() => Promise) | null = null + +export function setAuthHandlers( + logout: () => Promise, + tryRefreshToken: () => Promise, +) { + globalLogout = logout + globalTryRefreshToken = tryRefreshToken +} + +// Track ongoing refresh attempts to prevent multiple simultaneous refreshes +let tokenRefreshPromise: Promise | null = null +let isRefreshInProgress = false + +export async function authFetch(input: RequestInfo, init: RequestInit = {}) { + const token = getTokenOrApiKey() + + if ( + !token && + (!init.headers || + !(init.headers as { [key: string]: string })['Authorization']) && + globalTryRefreshToken && + globalLogout && + !isRefreshInProgress + ) { + isRefreshInProgress = true + try { + const refreshed = await globalTryRefreshToken() + if (!refreshed) { + await globalLogout() + throw new Error('Authentication failed') + } + } finally { + isRefreshInProgress = false + } + } + + // Use the latest JWT + const finalToken = getTokenOrApiKey() + + const makeRequest = (authToken?: { token: string; type: 'jwt' | 'api' }) => { + return fetch(input, { + ...init, + // Normalize Authorization headers + headers: { + Authorization: authToken + ? `${authToken.type === 'jwt' ? 'Bearer' : 'ApiKey'} ${authToken.token}` + : finalToken + ? `${finalToken.type === 'jwt' ? 'Bearer' : 'ApiKey'} ${finalToken.token}` + : '', + ...(init.headers ?? {}), + }, + }) + } + + const response = await makeRequest() + // Try the request up to 3 times before attempting token refresh + let attempt = 1 + let resp = response + while (resp.status >= 500 && resp.status !== 401 && attempt < 3) { + await new Promise((res) => setTimeout(res, 300 * attempt)) // Exponential backoff + attempt++ + resp = await makeRequest() + if (resp.status < 500 && resp.status !== 401) { + return resp + } + } + // If we get a 401 and we have refresh handlers, try to refresh the token + if (resp.status === 401 && globalTryRefreshToken && globalLogout) { + console.log('Received 401, attempting token refresh...') + + // If there's already a refresh in progress, wait for it + if (tokenRefreshPromise) { + const refreshSuccess = await tokenRefreshPromise + if (refreshSuccess) { + // Retry the original request with the new token + const newToken = localStorage.getItem('jwt')! + return makeRequest({ token: newToken, type: 'jwt' }) + } else { + // Refresh failed, logout + await globalLogout() + throw new Error('Authentication failed - logged out') + } + } + + // Start a new refresh attempt + tokenRefreshPromise = globalTryRefreshToken() + + try { + const tokenRefreshSuccess = await tokenRefreshPromise.catch((error) => { + console.error('Token refresh error: ' + error) + return false + }) + if (tokenRefreshSuccess) { + console.log('Token refresh successful, retrying request...') + // Retry the original request with the new token + const newToken = localStorage.getItem('jwt')! + const retryResponse = await makeRequest({ + token: newToken, + type: 'jwt', + }) + + // If the retry also fails with 401, logout + if (retryResponse.status === 401) { + console.log('Retry request also failed with 401, logging out...') + await globalLogout() + throw new Error( + 'Authentication failed after attempting token refresh - logged out', + ) + } + + return retryResponse + } else { + console.log( + `Token refresh failed, logging out... (${await tokenRefreshPromise})`, + ) + await globalLogout() + throw new Error('Token refresh failed - logged out') + } + } finally { + // Clear the refresh promise when done + tokenRefreshPromise = null + } + } + + return response +} + +// Ask for notification permission +export async function requestNotificationPermission() { + if ('Notification' in window && Notification.permission !== 'granted') { + await Notification.requestPermission() + } +} +// Fetch with retry logic for rate limiting + +export async function fetchWithRetry( + fetchFn: () => Promise, + tries = 3, + delay = 1000, +): Promise { + for (let i = 0; i < tries; i++) { + try { + const response = await fetchFn() + + if (response.status === 429) { + console.warn(`Rate limit hit, attempt ${i + 1}/${tries}`) + if (i === tries - 1) { + throw new Error('Rate limit exceeded after multiple attempts') + } + // Exponential backoff: 1s, 2s, 4s + const waitTime = delay * Math.pow(2, i) + console.log(`Waiting ${waitTime}ms before retry...`) + await new Promise((resolve) => setTimeout(resolve, waitTime)) + continue + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response + } catch (error) { + console.error(`Fetch attempt ${i + 1} failed:`, error) + if (i === tries - 1) { + throw error + } + // Wait before retry even for non-429 errors + const waitTime = delay * Math.pow(2, i) + await new Promise((resolve) => setTimeout(resolve, waitTime)) + } + } + throw new Error('Max retries exceeded') +} + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + , + , +) diff --git a/web2/src/secure-storage.ts b/web2/src/secure-storage.ts new file mode 100644 index 0000000..ac2b973 --- /dev/null +++ b/web2/src/secure-storage.ts @@ -0,0 +1,144 @@ +class SecureStorageService { + private isSecureContext(): boolean { + return ( + window.isSecureContext || + location.protocol === 'https:' || + location.hostname === 'localhost' + ) + } + + private async getEncryptionKey(): Promise { + // Only use Web Crypto API in secure contexts + if (!this.isSecureContext() || !window.crypto?.subtle) { + return null + } + + try { + // Try to get existing key from IndexedDB or generate new one + const keyData = localStorage.getItem('_encKey') + if (keyData) { + const rawKey = new Uint8Array(JSON.parse(keyData)) + return await window.crypto.subtle.importKey( + 'raw', + rawKey, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'], + ) + } + + // Generate new key + const key = await window.crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + ) + + // Export and store key + const rawKey = await window.crypto.subtle.exportKey('raw', key) + localStorage.setItem( + '_encKey', + JSON.stringify(Array.from(new Uint8Array(rawKey))), + ) + + return key + } catch (error) { + console.warn('Failed to get encryption key:', error) + return null + } + } + + private async encrypt(text: string): Promise { + const key = await this.getEncryptionKey() + + // Fallback to base64 encoding if no encryption available + if (!key || !window.crypto?.subtle) { + console.warn('Encryption not available, using base64 encoding') + return btoa(text) + } + + try { + const iv = window.crypto.getRandomValues(new Uint8Array(12)) + const encoded = new TextEncoder().encode(text) + const encrypted = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encoded, + ) + + return JSON.stringify({ + iv: Array.from(iv), + data: Array.from(new Uint8Array(encrypted)), + }) + } catch (error) { + console.warn('Encryption failed, using base64 fallback:', error) + return btoa(text) + } + } + + private async decrypt(encryptedText: string): Promise { + const key = await this.getEncryptionKey() + + // Handle base64 fallback + if (!key || !window.crypto?.subtle) { + try { + return atob(encryptedText) + } catch (error) { + console.error('Failed to decode base64:', error) + throw new Error('Failed to decrypt token') + } + } + + try { + // Try to parse as encrypted JSON first + const { iv, data } = JSON.parse(encryptedText) + const decrypted = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: new Uint8Array(iv) }, + key, + new Uint8Array(data), + ) + + return new TextDecoder().decode(decrypted) + } catch (error) { + // Fallback to base64 decoding for legacy tokens + try { + return atob(encryptedText) + } catch (error2) { + console.error('Failed to decrypt token:', error) + console.error(error2) + throw new Error('Failed to decrypt token') + } + } + } + + async setRefreshToken(token: string): Promise { + const encrypted = await this.encrypt(token) + localStorage.setItem('_rt', encrypted) + } + + async getRefreshToken(): Promise { + const encrypted = localStorage.getItem('_rt') + if (!encrypted) return null + + try { + return await this.decrypt(encrypted) + } catch (error) { + console.error('Failed to decrypt refresh token:', error) + // Clear corrupted token + localStorage.removeItem('_rt') + return null + } + } + + async removeRefreshToken(): Promise { + localStorage.removeItem('_rt') + } + + async clearAll(): Promise { + localStorage.removeItem('_rt') + localStorage.removeItem('_encKey') + } +} + +const SecureStorage = new SecureStorageService() +export default SecureStorage diff --git a/web2/src/styles.css b/web2/src/styles.css new file mode 100644 index 0000000..9052d85 --- /dev/null +++ b/web2/src/styles.css @@ -0,0 +1,47 @@ +.mantine-NavLink-root { + border-radius: 5px; +} + +.mantine-Lightbox-overlay, +.mantine-Lightbox-content { + z-index: 1002 !important; +} + +.mantine-Splitter-thumb { + &:where([data-orientation='horizontal']) { + width: rem(16px) !important; + height: rem(60px); + } + + &:where([data-orientation='vertical']) { + width: rem(60px); + height: rem(16px) !important; + } +} + +.mantine-Splitter-pane { + display: flex; + justify-content: center; + align-items: center; + overflow: hidden !important; +} + +.mantine-Combobox-dropdown { + z-index: 1001 !important; +} + +/* Hide scrollbar for the entire page */ +html, +body { + /* Firefox */ + scrollbar-width: none; + + /* IE and Legacy Edge */ + -ms-overflow-style: none; +} + +/* Chrome, Safari, and Opera */ +html::-webkit-scrollbar, +body::-webkit-scrollbar { + display: none; +} diff --git a/web2/src/theme.ts b/web2/src/theme.ts new file mode 100644 index 0000000..0cbd310 --- /dev/null +++ b/web2/src/theme.ts @@ -0,0 +1,5 @@ +import { createTheme } from '@mantine/core' + +export default createTheme({ + /* Put your mantine theme override here */ +}) diff --git a/web2/src/types.tsx b/web2/src/types.tsx new file mode 100644 index 0000000..64d553d --- /dev/null +++ b/web2/src/types.tsx @@ -0,0 +1,17 @@ +export type Recording = { + streamId: string + filename: string + nickname: string + duration: number + motionTimestamps: number[] +} + +export interface Stream { + id: string + nickname: string + ffmpegInput: string + rtspUser?: string + rtspPass?: string + createdAt: string + updatedAt?: string +} diff --git a/web2/src/vite-env.d.ts b/web2/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web2/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web2/tsconfig.app.json b/web2/tsconfig.app.json new file mode 100644 index 0000000..5e90c0f --- /dev/null +++ b/web2/tsconfig.app.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "composite": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "../source/types/shared.ts"] +} diff --git a/web2/tsconfig.json b/web2/tsconfig.json new file mode 100644 index 0000000..b53262d --- /dev/null +++ b/web2/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "noCheck": true + }, + "files": [ + "../eslint.config.mts", + "../prisma.config.ts", + "../capacitor.config.ts" + ], + "include": ["../source/**/*.ts"], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web2/tsconfig.node.json b/web2/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/web2/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web2/vite.config.ts b/web2/vite.config.ts new file mode 100644 index 0000000..0bb3928 --- /dev/null +++ b/web2/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +console.log(__dirname) + +// https://vitejs.dev/config/ +export default defineConfig(({ mode }) => ({ + plugins: [react()], + ...(mode === 'development' + ? { + build: { manifest: true, sourcemap: true }, + } + : {}), +})) diff --git a/yarn.lock b/yarn.lock index 9730f43..b665634 100644 --- a/yarn.lock +++ b/yarn.lock @@ -185,6 +185,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7": + version: 7.29.7 + resolution: "@babel/runtime@npm:7.29.7" + checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e + languageName: node + linkType: hard + "@babel/template@npm:^7.29.7": version: 7.29.7 resolution: "@babel/template@npm:7.29.7" @@ -413,6 +420,13 @@ __metadata: languageName: node linkType: hard +"@epic-web/invariant@npm:^1.0.0": + version: 1.0.0 + resolution: "@epic-web/invariant@npm:1.0.0" + checksum: 10c0/72dbeb026e4e4eb3bc9c65739b91408ca77ab7d603a2494fa2eff3790ec22892c4caba751cffdf30f5ccf0e7ba79c1e9c96cf0a357404b9432bf1365baac23ca + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.28.1": version: 0.28.1 resolution: "@esbuild/aix-ppc64@npm:0.28.1" @@ -808,6 +822,74 @@ __metadata: languageName: node linkType: hard +"@floating-ui/core@npm:^1.8.0": + version: 1.8.0 + resolution: "@floating-ui/core@npm:1.8.0" + dependencies: + "@floating-ui/utils": "npm:^0.2.12" + checksum: 10c0/cece958f6fab0f8cd7740cd57988c4f8bc5356dca39dd4c093433d44c91952c3a02c7a54d93d7ed6c078b6544d531a29f66621d2839c8bfbb4467dca6aed7df0 + languageName: node + linkType: hard + +"@floating-ui/dom@npm:^1.8.0": + version: 1.8.0 + resolution: "@floating-ui/dom@npm:1.8.0" + dependencies: + "@floating-ui/core": "npm:^1.8.0" + "@floating-ui/utils": "npm:^0.2.12" + checksum: 10c0/c32b2de7a596b69cfffa909c7028825e14f6319be1b45da529f8943c89fe16b75a49c214e800c87ded3f172353d3b99163d50072f11410488e756961b7b629cc + languageName: node + linkType: hard + +"@floating-ui/react-dom@npm:^2.1.9": + version: 2.1.9 + resolution: "@floating-ui/react-dom@npm:2.1.9" + dependencies: + "@floating-ui/dom": "npm:^1.8.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/9fa9851069738cb9591a1ab2da16e257f193eae5fd2d6d48ad0b1e73f3c049929b6805c5ad43d4af3c1da63c60b215b0ee4651a1fe5caeb80e66d9d423744348 + languageName: node + linkType: hard + +"@floating-ui/react@npm:^0.27.20": + version: 0.27.20 + resolution: "@floating-ui/react@npm:0.27.20" + dependencies: + "@floating-ui/react-dom": "npm:^2.1.9" + "@floating-ui/utils": "npm:^0.2.12" + tabbable: "npm:^6.0.0" + peerDependencies: + react: ">=17.0.0" + react-dom: ">=17.0.0" + checksum: 10c0/5ebeb4c877128e9a274692849ceb2103b370441626e97ade38c25db5d94371dd7274292eda05208cc5d1c92c594a59595a1909799c2ac8ff44f9e651a4fe55b3 + languageName: node + linkType: hard + +"@floating-ui/utils@npm:^0.2.12": + version: 0.2.12 + resolution: "@floating-ui/utils@npm:0.2.12" + checksum: 10c0/bb910b90d56991a62011afaf5f8e0c4b7fb6dab69403f4dd17fbd6eb25560b28d533dff9791f270b4f459852e9006a1077b5d73cbedb5e34d9424afc6a828314 + languageName: node + linkType: hard + +"@gfazioli/mantine-video@npm:1.2.2": + version: 1.2.2 + resolution: "@gfazioli/mantine-video@npm:1.2.2" + peerDependencies: + "@mantine/core": ">=9.0.0" + "@mantine/hooks": ">=9.0.0" + "@tabler/icons-react": ">=3.0.0" + react: ^18.x || ^19.x + react-dom: ^18.x || ^19.x + peerDependenciesMeta: + "@tabler/icons-react": + optional: true + checksum: 10c0/a2cd9da69eef2537cd107d704c4746171c790a323fe72c7ea48020bb1bfae33e3b367c3d81e8cff8d4b226ea9beecc8fce85bef1df375f649fdf401821e07bab + languageName: node + linkType: hard + "@google-cloud/firestore@npm:^7.11.0": version: 7.11.6 resolution: "@google-cloud/firestore@npm:7.11.6" @@ -1465,6 +1547,140 @@ __metadata: languageName: node linkType: hard +"@mantine/carousel@npm:9.6.1": + version: 9.6.1 + resolution: "@mantine/carousel@npm:9.6.1" + peerDependencies: + "@mantine/core": 9.6.1 + "@mantine/hooks": 9.6.1 + embla-carousel: ">=8.0.0" + embla-carousel-react: ">=8.0.0" + react: ^19.2.0 + react-dom: ^19.2.0 + checksum: 10c0/e02f3daa6484e1aa2d1eb8292804e521e2e7977aa1cc640e13676a113c5b52dc6f9deb69b184be34f8838c846ac321d7df27aa3ebcff760ce1685d4bd7b567ac + languageName: node + linkType: hard + +"@mantine/core@npm:9.6.1": + version: 9.6.1 + resolution: "@mantine/core@npm:9.6.1" + dependencies: + "@floating-ui/react": "npm:^0.27.20" + clsx: "npm:^2.1.1" + react-number-format: "npm:^5.4.5" + react-remove-scroll: "npm:^2.7.2" + type-fest: "npm:^5.8.0" + peerDependencies: + "@mantine/hooks": 9.6.1 + react: ^19.2.0 + react-dom: ^19.2.0 + checksum: 10c0/2891407a293cf5415ae3b9a5025dfce193e7051ab40c839e98f0b26c1b21c133b7bde250c441b9eca40e64691e4999d853825e12afb8ad35657597f2134f8efb + languageName: node + linkType: hard + +"@mantine/dates@npm:9.6.1": + version: 9.6.1 + resolution: "@mantine/dates@npm:9.6.1" + dependencies: + clsx: "npm:^2.1.1" + peerDependencies: + "@mantine/core": 9.6.1 + "@mantine/hooks": 9.6.1 + dayjs: ">=1.0.0" + react: ^19.2.0 + react-dom: ^19.2.0 + checksum: 10c0/307ac9b91b3268083737054aced77f021835a40dfd1c8f105ed09ffbdc1cc8b0481f65a62fc64137f002d01df0b0a0bfb082f7b08656cb2da27cae1a348c09f6 + languageName: node + linkType: hard + +"@mantine/form@npm:9.6.1": + version: 9.6.1 + resolution: "@mantine/form@npm:9.6.1" + dependencies: + "@standard-schema/spec": "npm:^1.1.0" + fast-deep-equal: "npm:^3.1.3" + klona: "npm:^2.0.6" + peerDependencies: + react: ^19.2.0 + checksum: 10c0/5f5d1ac4c131bdc065c31f0a78664cb2047d1a3fa10668add6cabdd22331a340bc8de75c78643e5e41a46d15697a10ee840495202240b3d7bca95470acf189e2 + languageName: node + linkType: hard + +"@mantine/hooks@npm:9.6.1": + version: 9.6.1 + resolution: "@mantine/hooks@npm:9.6.1" + peerDependencies: + react: ^19.2.0 + checksum: 10c0/74a2fa03d95fa825bd98afe4643372907619074599948c617b9bec018ad8a92523db77708a922f928e2a1326f2150bedd19f59011b56ec3c8d1d0631cb5c1b51 + languageName: node + linkType: hard + +"@mantine/lightbox@npm:9.6.1": + version: 9.6.1 + resolution: "@mantine/lightbox@npm:9.6.1" + dependencies: + "@mantine/store": "npm:9.6.1" + peerDependencies: + "@mantine/core": 9.6.1 + "@mantine/hooks": 9.6.1 + embla-carousel: ">=8.0.0" + embla-carousel-react: ">=8.0.0" + react: ^19.2.0 + react-dom: ^19.2.0 + checksum: 10c0/65a13d3e1a9b49276a8632f0335563d2949f2e32ebb864b82573b12ff0066ff4c751a158c62fd792ff6d5647bc96b2f28ac337bf96a9a8caede4d4cf51302274 + languageName: node + linkType: hard + +"@mantine/modals@npm:9.6.1": + version: 9.6.1 + resolution: "@mantine/modals@npm:9.6.1" + peerDependencies: + "@mantine/core": 9.6.1 + "@mantine/hooks": 9.6.1 + react: ^19.2.0 + react-dom: ^19.2.0 + checksum: 10c0/1aab37e63067f98c36c96cda384600bd14735c45a510ba76f679d03d6e7819ea8093f457cf3a8d394c6f7f8893d16c0eb0d0afc471a13c9f7dc3c070f6039068 + languageName: node + linkType: hard + +"@mantine/notifications@npm:9.6.1": + version: 9.6.1 + resolution: "@mantine/notifications@npm:9.6.1" + dependencies: + "@mantine/store": "npm:9.6.1" + react-transition-group: "npm:4.4.5" + peerDependencies: + "@mantine/core": 9.6.1 + "@mantine/hooks": 9.6.1 + react: ^19.2.0 + react-dom: ^19.2.0 + checksum: 10c0/f34e91f105235366580cfeb72452c7d69cc140dcd5c6dfffc225045f734c58f961d624e3d3318e9dd51173e07f76308e64a94a66fbcaea9f8342eaadb8390463 + languageName: node + linkType: hard + +"@mantine/nprogress@npm:9.6.1": + version: 9.6.1 + resolution: "@mantine/nprogress@npm:9.6.1" + dependencies: + "@mantine/store": "npm:9.6.1" + peerDependencies: + "@mantine/core": 9.6.1 + "@mantine/hooks": 9.6.1 + react: ^19.2.0 + react-dom: ^19.2.0 + checksum: 10c0/a27375d64d68062588198cd69c1e4caa3260386729bdcfa1267311c276be88f127556c101df566f36e823ca2ebc6415e34f2af0374004c1d3d24df27b0dab661 + languageName: node + linkType: hard + +"@mantine/store@npm:9.6.1": + version: 9.6.1 + resolution: "@mantine/store@npm:9.6.1" + peerDependencies: + react: ^19.2.0 + checksum: 10c0/4cdca1ec30ae66ba0c7e2fd48c1461386fee6a79f8fd8070dae91d1769adce39181f7afd53310393c16d41fb972630b0f15c1e31540e7c8c164556cf992e46ef + languageName: node + linkType: hard + "@napi-rs/wasm-runtime@npm:^1.1.4": version: 1.1.4 resolution: "@napi-rs/wasm-runtime@npm:1.1.4" @@ -2112,7 +2328,7 @@ __metadata: languageName: node linkType: hard -"@rolldown/pluginutils@npm:^1.0.0": +"@rolldown/pluginutils@npm:^1.0.0, @rolldown/pluginutils@npm:^1.0.1": version: 1.0.1 resolution: "@rolldown/pluginutils@npm:1.0.1" checksum: 10c0/99d9b06d90196823e4d8c841f258db7a16e5dbba5824a2962b05d907b79f1ba929d56f22dd744fd530936e568c865ee56a719dc31e57e13bc0a8eb4764a8d8dd @@ -2261,6 +2477,13 @@ __metadata: languageName: node linkType: hard +"@standard-schema/spec@npm:^1.1.0": + version: 1.1.0 + resolution: "@standard-schema/spec@npm:1.1.0" + checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526 + languageName: node + linkType: hard + "@stylistic/eslint-plugin@npm:5.10.0": version: 5.10.0 resolution: "@stylistic/eslint-plugin@npm:5.10.0" @@ -2286,6 +2509,24 @@ __metadata: languageName: node linkType: hard +"@tabler/icons-react@npm:3.46.0": + version: 3.46.0 + resolution: "@tabler/icons-react@npm:3.46.0" + dependencies: + "@tabler/icons": "npm:3.46.0" + peerDependencies: + react: ">= 16" + checksum: 10c0/8fab7d3a9ec6b62724a1628b8bad68a7b503979e4418f97d057c442adc5257b227a08185a884d452d171983648e36fa92cd7bfe9457f693e9b67bbf4ecf8ec24 + languageName: node + linkType: hard + +"@tabler/icons@npm:3.46.0": + version: 3.46.0 + resolution: "@tabler/icons@npm:3.46.0" + checksum: 10c0/c9671449c2f53fa7e329c487162f89a28417cdd5ce96cfa290fe21388724da303dd2297af3b39a88a74b02c36ce55598bc3cf6537dbddf548881da2c05f35f5e + languageName: node + linkType: hard + "@tokenizer/inflate@npm:^0.4.1": version: 0.4.1 resolution: "@tokenizer/inflate@npm:0.4.1" @@ -2602,12 +2843,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:25.6.0": - version: 25.6.0 - resolution: "@types/node@npm:25.6.0" +"@types/node@npm:25.9.7": + version: 25.9.7 + resolution: "@types/node@npm:25.9.7" dependencies: - undici-types: "npm:~7.19.0" - checksum: 10c0/d2d2015630ff098a201407f55f5077a20270ae4f465c739b40865cd9933b91b9c5d2b85568eadaf3db0801b91e267333ca7eb39f007428b173d1cdab4b339ac5 + undici-types: "npm:>=7.24.0 <7.24.7" + checksum: 10c0/455891a50ac28558eeccdf7439b887bb8923aa11c2c4efd900d8f8b4e3c1d204905290abd72f80425d1e39793c404b45ac70814c4831e12034cf0ce503ebe981 languageName: node linkType: hard @@ -2634,12 +2875,12 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:19.2.3": - version: 19.2.3 - resolution: "@types/react-dom@npm:19.2.3" +"@types/react-dom@npm:19.3.0": + version: 19.3.0 + resolution: "@types/react-dom@npm:19.3.0" peerDependencies: - "@types/react": ^19.2.0 - checksum: 10c0/b486ebe0f4e2fb35e2e108df1d8fc0927ca5d6002d5771e8a739de11239fe62d0e207c50886185253c99eb9dedfeeb956ea7429e5ba17f6693c7acb4c02f8cd1 + "@types/react": ^19.3.0 + checksum: 10c0/ce0066d28b62bc6afdd17ca84756b8fbe0f6ffc0c8b1e60739abc30e81510da2edbc1007e6ab3d974d30f5a8c25bb0f7de8d7ce09736c909fe84c0f9aed0b946 languageName: node linkType: hard @@ -2661,12 +2902,12 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:19.2.17": - version: 19.2.17 - resolution: "@types/react@npm:19.2.17" +"@types/react@npm:19.3.0": + version: 19.3.0 + resolution: "@types/react@npm:19.3.0" dependencies: csstype: "npm:^3.2.2" - checksum: 10c0/bc2c4af96b3e480604424de70d5ebda90c5f4b485df471858c0bc2d7d70364b606ec3c4d8579f94f01aa0c6c0591f56bcf14cba5689f5eea4b74250ccdc3a232 + checksum: 10c0/fbcabf303f935ca9f36ed2dcf81751cf84744098c5a683c8a62a0fa7edd31714b48d5c46b12fd0014f7f2dfe4d9ec6de428a850748eec719a70294e51029551b languageName: node linkType: hard @@ -2945,6 +3186,27 @@ __metadata: languageName: node linkType: hard +"@vitejs/plugin-react@npm:6.1.1": + version: 6.1.1 + resolution: "@vitejs/plugin-react@npm:6.1.1" + dependencies: + "@rolldown/pluginutils": "npm:^1.0.1" + peerDependencies: + "@rolldown/plugin-babel": ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + "@rolldown/plugin-babel": + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + checksum: 10c0/4599b37ee19ebee289782b177642e3002afa9b74cb57a2c0e2a601053c7ebb47f9ef178ab605ff1217de1d23069948a8d366447bfac0d9fb2f17afee5368acd0 + languageName: node + linkType: hard + "@xmldom/xmldom@npm:^0.8.8": version: 0.8.13 resolution: "@xmldom/xmldom@npm:0.8.13" @@ -3809,6 +4071,13 @@ __metadata: languageName: node linkType: hard +"camelcase-css@npm:^2.0.1": + version: 2.0.1 + resolution: "camelcase-css@npm:2.0.1" + checksum: 10c0/1a1a3137e8a781e6cbeaeab75634c60ffd8e27850de410c162cce222ea331cd1ba5364e8fb21c95e5ca76f52ac34b81a090925ca00a87221355746d049c6e273 + languageName: node + linkType: hard + "camelcase@npm:^5.3.1": version: 5.3.1 resolution: "camelcase@npm:5.3.1" @@ -3993,6 +4262,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^2.1.1": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -4207,6 +4483,19 @@ __metadata: languageName: node linkType: hard +"cross-env@npm:10.1.0": + version: 10.1.0 + resolution: "cross-env@npm:10.1.0" + dependencies: + "@epic-web/invariant": "npm:^1.0.0" + cross-spawn: "npm:^7.0.6" + bin: + cross-env: dist/bin/cross-env.js + cross-env-shell: dist/bin/cross-env-shell.js + checksum: 10c0/834a862db456ba1fedf6c6da43436b123ae38f514fa286d6f0937c14fa83f13469f77f70f2812db041ae2d84f82bac627040b8686030aca27fbdf113dfa38b63 + languageName: node + linkType: hard + "cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" @@ -4218,7 +4507,16 @@ __metadata: languageName: node linkType: hard -"csstype@npm:^3.2.2": +"cssesc@npm:^3.0.0": + version: 3.0.0 + resolution: "cssesc@npm:3.0.0" + bin: + cssesc: bin/cssesc + checksum: 10c0/6bcfd898662671be15ae7827120472c5667afb3d7429f1f917737f3bf84c4176003228131b643ae74543f17a394446247df090c597bb9a728cce298606ed0aa7 + languageName: node + linkType: hard + +"csstype@npm:^3.0.2, csstype@npm:^3.2.2": version: 3.2.3 resolution: "csstype@npm:3.2.3" checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce @@ -4293,6 +4591,13 @@ __metadata: languageName: node linkType: hard +"dayjs@npm:1.11.23": + version: 1.11.23 + resolution: "dayjs@npm:1.11.23" + checksum: 10c0/69ab04bf19c676e44ab50cc2fca223d265f3dafd107151ef3b0f3ad74d360c37caa602abe62f87cb25d90bd9f6bee003698af47df5b0dbf10a998b7b5f3bb3f1 + languageName: node + linkType: hard + "dayjs@npm:~1.8.24": version: 1.8.36 resolution: "dayjs@npm:1.8.36" @@ -4485,6 +4790,13 @@ __metadata: languageName: node linkType: hard +"detect-node-es@npm:^1.1.0": + version: 1.1.0 + resolution: "detect-node-es@npm:1.1.0" + checksum: 10c0/e562f00de23f10c27d7119e1af0e7388407eb4b06596a25f6d79a360094a109ff285de317f02b090faae093d314cf6e73ac3214f8a5bb3a0def5bece94557fbe + languageName: node + linkType: hard + "diff@npm:^5.1.0": version: 5.2.2 resolution: "diff@npm:5.2.2" @@ -4501,6 +4813,16 @@ __metadata: languageName: node linkType: hard +"dom-helpers@npm:^5.0.1": + version: 5.2.1 + resolution: "dom-helpers@npm:5.2.1" + dependencies: + "@babel/runtime": "npm:^7.8.7" + csstype: "npm:^3.0.2" + checksum: 10c0/f735074d66dd759b36b158fa26e9d00c9388ee0e8c9b16af941c38f014a37fc80782de83afefd621681b19ac0501034b4f1c4a3bff5caa1b8667f0212b5e124c + languageName: node + linkType: hard + "dotenv@npm:^16.3.1": version: 16.6.1 resolution: "dotenv@npm:16.6.1" @@ -4606,6 +4928,34 @@ __metadata: languageName: node linkType: hard +"embla-carousel-react@npm:8.6.0": + version: 8.6.0 + resolution: "embla-carousel-react@npm:8.6.0" + dependencies: + embla-carousel: "npm:8.6.0" + embla-carousel-reactive-utils: "npm:8.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + checksum: 10c0/461b0c427a6fca62e730c7b39d08e9c55b01a2138c173d5b9ebf77ec92092a900b45ac384731c77382b48d5d65946994d3abba6e9899a66d6e16f8ddf191ed1c + languageName: node + linkType: hard + +"embla-carousel-reactive-utils@npm:8.6.0": + version: 8.6.0 + resolution: "embla-carousel-reactive-utils@npm:8.6.0" + peerDependencies: + embla-carousel: 8.6.0 + checksum: 10c0/33d07e6df4d8dec9b4fc95858838ea2d5e5df078039c05b2a342c05ba4979d8d18564a1c607da445fed744daf969a0b5a90124ce487a854e86ced6f0458b51e9 + languageName: node + linkType: hard + +"embla-carousel@npm:8.6.0": + version: 8.6.0 + resolution: "embla-carousel@npm:8.6.0" + checksum: 10c0/f4c598e7be28b70340d31ffd2bebb2472db370b0c81d9b089bf9555cf618695f35dc4a0694565c994c9ab972731123063f945aa09ff485df0df761d79c6a08ef + languageName: node + linkType: hard + "emoji-regex@npm:^10.3.0": version: 10.6.0 resolution: "emoji-regex@npm:10.6.0" @@ -5876,7 +6226,7 @@ __metadata: "@types/express-session": "npm:1.19.0" "@types/greenlock-express": "npm:4.0.3" "@types/jsonwebtoken": "npm:9.0.10" - "@types/node": "npm:25.6.0" + "@types/node": "npm:25.9.7" "@types/session-file-store": "npm:1.2.6" "@types/web-push": "npm:3.6.4" "@typescript-eslint/eslint-plugin": "npm:8.63.0" @@ -5889,6 +6239,7 @@ __metadata: cookie-parser: "npm:1.4.7" copyfiles: "npm:2.4.1" cors: "npm:2.8.6" + cross-env: "npm:10.1.0" eslint: "npm:10.3.0" eslint-config-prettier: "npm:10.1.8" eslint-plugin-import: "npm:2.32.0" @@ -6029,6 +6380,13 @@ __metadata: languageName: node linkType: hard +"get-nonce@npm:^1.0.0": + version: 1.0.1 + resolution: "get-nonce@npm:1.0.1" + checksum: 10c0/2d7df55279060bf0568549e1ffc9b84bc32a32b7541675ca092dce56317cdd1a59a98dcc4072c9f6a980779440139a3221d7486f52c488e69dc0fd27b1efb162 + languageName: node + linkType: hard + "get-port-please@npm:3.2.0": version: 3.2.0 resolution: "get-port-please@npm:3.2.0" @@ -6449,6 +6807,13 @@ __metadata: languageName: node linkType: hard +"hls.js@npm:1.7.1": + version: 1.7.1 + resolution: "hls.js@npm:1.7.1" + checksum: 10c0/316762dd811aaacf9e551a8a635f6b24b07333e9b45cf854f0c1031f3340d91de76b6205de9f92e7b753ce0bcb0dd69489671131071de2419e5bdecbf0d182b6 + languageName: node + linkType: hard + "hono@npm:^4.12.8": version: 4.12.26 resolution: "hono@npm:4.12.26" @@ -7180,7 +7545,7 @@ __metadata: languageName: node linkType: hard -"js-tokens@npm:^4.0.0": +"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed @@ -7423,6 +7788,13 @@ __metadata: languageName: node linkType: hard +"klona@npm:^2.0.6": + version: 2.0.6 + resolution: "klona@npm:2.0.6" + checksum: 10c0/94eed2c6c2ce99f409df9186a96340558897b3e62a85afdc1ee39103954d2ebe1c1c4e9fe2b0952771771fa96d70055ede8b27962a7021406374fdb695fd4d01 + languageName: node + linkType: hard + "kruptein@npm:^2.0.4": version: 2.2.3 resolution: "kruptein@npm:2.2.3" @@ -7655,6 +8027,17 @@ __metadata: languageName: node linkType: hard +"loose-envify@npm:^1.4.0": + version: 1.4.0 + resolution: "loose-envify@npm:1.4.0" + dependencies: + js-tokens: "npm:^3.0.0 || ^4.0.0" + bin: + loose-envify: cli.js + checksum: 10c0/655d110220983c1a4b9c0c679a2e8016d4b67f6e9c7b5435ff5979ecdb20d0813f4dec0a08674fcbdd4846a3f07edbb50a36811fd37930b94aaa0d9daceb017e + languageName: node + linkType: hard + "lowercase-keys@npm:^2.0.0": version: 2.0.0 resolution: "lowercase-keys@npm:2.0.0" @@ -8048,6 +8431,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^3.3.17": + version: 3.3.18 + resolution: "nanoid@npm:3.3.18" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/b994b4e396730f8be2520923284e2040d61eaee55cc6d4935ef6d38d34bafdc46133eda4d3faea5073bda545aa6079d82b886caeac5c731cf9ac18bcc1301425 + languageName: node + linkType: hard + "napi-build-utils@npm:^2.0.0": version: 2.0.0 resolution: "napi-build-utils@npm:2.0.0" @@ -8855,6 +9247,84 @@ __metadata: languageName: node linkType: hard +"postcss-js@npm:^4.0.1": + version: 4.1.0 + resolution: "postcss-js@npm:4.1.0" + dependencies: + camelcase-css: "npm:^2.0.1" + peerDependencies: + postcss: ^8.4.21 + checksum: 10c0/a3cf6e725f3e9ecd7209732f8844a0063a1380b718ccbcf93832b6ec2cd7e63ff70dd2fed49eb2483c7482296860a0f7badd3115b5d0fa05ea648eb6d9dfc9c6 + languageName: node + linkType: hard + +"postcss-mixins@npm:^12.0.0": + version: 12.1.2 + resolution: "postcss-mixins@npm:12.1.2" + dependencies: + postcss-js: "npm:^4.0.1" + postcss-simple-vars: "npm:^7.0.1" + sugarss: "npm:^5.0.0" + tinyglobby: "npm:^0.2.14" + peerDependencies: + postcss: ^8.2.14 + checksum: 10c0/cbd33a40545ebcaa08acc9126a6cbffc48db968ea5a555c397b3c5b30e0f805fb2de3381556b36efe3dc9eb935d51c6056b86d6367fb6204f6c1ae492017524d + languageName: node + linkType: hard + +"postcss-nested@npm:^7.0.2": + version: 7.0.2 + resolution: "postcss-nested@npm:7.0.2" + dependencies: + postcss-selector-parser: "npm:^7.0.0" + peerDependencies: + postcss: ^8.2.14 + checksum: 10c0/b467f328c89c5b0dcd1b569ae083f6163235c0cac09494a0b284eef5275fa1386bbc26c66c85adf194cef0a65d315994140724a34539a096e5acd39d9c753338 + languageName: node + linkType: hard + +"postcss-preset-mantine@npm:1.18.0": + version: 1.18.0 + resolution: "postcss-preset-mantine@npm:1.18.0" + dependencies: + postcss-mixins: "npm:^12.0.0" + postcss-nested: "npm:^7.0.2" + peerDependencies: + postcss: ">=8.0.0" + checksum: 10c0/11308684261220a88f8607c10846777b7f83a8b44d6e787ed803a5ee4ceabbc447ffbaaccd34da8da650907d502c0703b8245cecf42b2714def1e8991dcec81d + languageName: node + linkType: hard + +"postcss-selector-parser@npm:^7.0.0": + version: 7.1.5 + resolution: "postcss-selector-parser@npm:7.1.5" + dependencies: + cssesc: "npm:^3.0.0" + util-deprecate: "npm:^1.0.2" + checksum: 10c0/e277a136ef87ae2abbc77d38e0773bf6245975889ada3d7d785f515df4d2ab543322f9189e851d6a6bcd2d8e512a1164d1dfd5af2f5bbb2d177c687faf945038 + languageName: node + linkType: hard + +"postcss-simple-vars@npm:7.0.1, postcss-simple-vars@npm:^7.0.1": + version: 7.0.1 + resolution: "postcss-simple-vars@npm:7.0.1" + peerDependencies: + postcss: ^8.2.1 + checksum: 10c0/b59c750b2f406a18b133184144b5ffefbd21a2179c5bf5d54651269c0b6bb0110c2bbed33a9161d8b93f8a11cf7725da0a65653e7c2786c8a59f937591115a33 + languageName: node + linkType: hard + +"postcss@npm:8.5.26": + version: 8.5.26 + resolution: "postcss@npm:8.5.26" + dependencies: + nanoid: "npm:^3.3.17" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/2bdafc00d96bd57b6649a52e458864a4bf58ee56cfdbe4aea1472b5cccc127e6c1ad653bd0bec50d211e650eb0b9270c80e1e72aff2e2fa40d9e7363234d6e43 + languageName: node + linkType: hard + "postcss@npm:^8.5.15": version: 8.5.15 resolution: "postcss@npm:8.5.15" @@ -8994,6 +9464,17 @@ __metadata: languageName: node linkType: hard +"prop-types@npm:^15.6.2": + version: 15.8.1 + resolution: "prop-types@npm:15.8.1" + dependencies: + loose-envify: "npm:^1.4.0" + object-assign: "npm:^4.1.1" + react-is: "npm:^16.13.1" + checksum: 10c0/59ece7ca2fb9838031d73a48d4becb9a7cc1ed10e610517c7d8f19a1e02fa47f7c27d557d8a5702bec3cfeccddc853579832b43f449e54635803f277b1c78077 + languageName: node + linkType: hard + "proper-lockfile@npm:4.1.2, proper-lockfile@npm:^4.1.2": version: 4.1.2 resolution: "proper-lockfile@npm:4.1.2" @@ -9170,23 +9651,75 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:19.2.8": - version: 19.2.8 - resolution: "react-dom@npm:19.2.8" +"react-dom@npm:19.3.0": + version: 19.3.0 + resolution: "react-dom@npm:19.3.0" dependencies: - scheduler: "npm:^0.27.0" + scheduler: "npm:^0.28.0" peerDependencies: - react: ^19.2.8 - checksum: 10c0/41ba2247b76f687fcfe5bbc99f514d6b851d8c8041c2f5ded36ed05bd7fdc5208cacbac9de51e3e6633e77f96f44cec9f0d4a5a55184dba4e00738f224439134 + react: ^19.3.0 + checksum: 10c0/c513044b724dd6bfe9500a9682d60973e7f8a48cffba00b6b16d3e910e27dcc459bf71bf0a5c33d7d62a3843ca30de1bd1e2098058aa4ed71f618a24b6061da5 languageName: node linkType: hard -"react-icons@npm:5.6.0": - version: 5.6.0 - resolution: "react-icons@npm:5.6.0" +"react-icons@npm:5.7.0": + version: 5.7.0 + resolution: "react-icons@npm:5.7.0" peerDependencies: react: "*" - checksum: 10c0/addba1ebfd45cdf7f69291d0c93df64fca1271cfdb66df657abd223e3451c73356b035f50e75858627dd182b02129596c79eaaaa45d32eb52658e443a992645c + checksum: 10c0/cc5ae92e61ef9d8fc83cb3f3ac2710deab9174c5831bdaf2a94b099acfc91074a60d4b3adfd3165dcb9b21799ca6c760b3f4f6596d75198c1ed2ea44240818c8 + languageName: node + linkType: hard + +"react-is@npm:^16.13.1": + version: 16.13.1 + resolution: "react-is@npm:16.13.1" + checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1 + languageName: node + linkType: hard + +"react-number-format@npm:^5.4.5": + version: 5.4.5 + resolution: "react-number-format@npm:5.4.5" + peerDependencies: + react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/bc5570a48542622f6c9068be616e87cc5280a67bb7f5a8ae13fe7a67e42f6ec12c244453b967b76737d775704ef792183bd8a47b23c225535342eccb7b1fcbb0 + languageName: node + linkType: hard + +"react-remove-scroll-bar@npm:^2.3.7": + version: 2.3.8 + resolution: "react-remove-scroll-bar@npm:2.3.8" + dependencies: + react-style-singleton: "npm:^2.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/9a0675c66cbb52c325bdbfaed80987a829c4504cefd8ff2dd3b6b3afc9a1500b8ec57b212e92c1fb654396d07bbe18830a8146fe77677d2a29ce40b5e1f78654 + languageName: node + linkType: hard + +"react-remove-scroll@npm:^2.7.2": + version: 2.7.2 + resolution: "react-remove-scroll@npm:2.7.2" + dependencies: + react-remove-scroll-bar: "npm:^2.3.7" + react-style-singleton: "npm:^2.2.3" + tslib: "npm:^2.1.0" + use-callback-ref: "npm:^1.3.3" + use-sidecar: "npm:^1.1.3" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/b5f3315bead75e72853f492c0b51ba8fb4fa09a4534d7fc42d6fcd59ca3e047cf213279ffc1e35b337e314ef5a04cb2b12544c85e0078802271731c27c09e5aa languageName: node linkType: hard @@ -9218,6 +9751,37 @@ __metadata: languageName: node linkType: hard +"react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3": + version: 2.2.3 + resolution: "react-style-singleton@npm:2.2.3" + dependencies: + get-nonce: "npm:^1.0.0" + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/841938ff16d16a6b76895f4cb2e1fea957e5fe3b30febbf03a54892dae1c9153f2383e231dea0b3ba41192ad2f2849448fa859caccd288943bce32639e971bee + languageName: node + linkType: hard + +"react-transition-group@npm:4.4.5": + version: 4.4.5 + resolution: "react-transition-group@npm:4.4.5" + dependencies: + "@babel/runtime": "npm:^7.5.5" + dom-helpers: "npm:^5.0.1" + loose-envify: "npm:^1.4.0" + prop-types: "npm:^15.6.2" + peerDependencies: + react: ">=16.6.0" + react-dom: ">=16.6.0" + checksum: 10c0/2ba754ba748faefa15f87c96dfa700d5525054a0141de8c75763aae6734af0740e77e11261a1e8f4ffc08fd9ab78510122e05c21c2d79066c38bb6861a886c82 + languageName: node + linkType: hard + "react-window@npm:1.8.11": version: 1.8.11 resolution: "react-window@npm:1.8.11" @@ -9231,10 +9795,10 @@ __metadata: languageName: node linkType: hard -"react@npm:19.2.8": - version: 19.2.8 - resolution: "react@npm:19.2.8" - checksum: 10c0/5f86bdb56426652fd6d989d30a6f2e603c057272c47c9ca3a3fbe190a3a39ee9ccce937d63cfc039717abed1b8891d6a499134bc35311acc07eafdacd86537cd +"react@npm:19.3.0": + version: 19.3.0 + resolution: "react@npm:19.3.0" + checksum: 10c0/dd3099c3c43a7122bc01223bb26b0f8d5ba13cca569af6fb94a005fb54fa73223c9cb203bb226d40cfd49a82cbeef2e36ce84062a3a6d5b9fe8f4437694c7b52 languageName: node linkType: hard @@ -9676,10 +10240,10 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:^0.27.0": - version: 0.27.0 - resolution: "scheduler@npm:0.27.0" - checksum: 10c0/4f03048cb05a3c8fddc45813052251eca00688f413a3cee236d984a161da28db28ba71bd11e7a3dd02f7af84ab28d39fb311431d3b3772fed557945beb00c452 +"scheduler@npm:^0.28.0": + version: 0.28.0 + resolution: "scheduler@npm:0.28.0" + checksum: 10c0/783c8e5ce89d41273a02ed6dc0e3d24bd60e05c9e8f7e3adfc6c82e7c69935500d90e343f1ee26d592fc7504e8a2532573c6368def3e93bfe812391fa53efaf4 languageName: node linkType: hard @@ -10343,6 +10907,15 @@ __metadata: languageName: node linkType: hard +"sugarss@npm:^5.0.0": + version: 5.0.1 + resolution: "sugarss@npm:5.0.1" + peerDependencies: + postcss: ^8.3.3 + checksum: 10c0/479d0b194bdb731000a323a0addcd1ffe29e4b7a93eb5cbe3efc34f01beb4ef0397d448f3b654ba5afa4a0e526d6758bcce4f41713806cb6a5a9383982eda6c8 + languageName: node + linkType: hard + "supports-color@npm:10.2.2": version: 10.2.2 resolution: "supports-color@npm:10.2.2" @@ -10393,6 +10966,20 @@ __metadata: languageName: node linkType: hard +"tabbable@npm:^6.0.0": + version: 6.5.0 + resolution: "tabbable@npm:6.5.0" + checksum: 10c0/7d778af05a3093800265831198cad9d0024f3d65cdd4405007490f7430b42ff4e80060b4679f45281fb96ddbddc5fb895e8ea36e3ebdc811051e14acc9c7c02c + languageName: node + linkType: hard + +"tagged-tag@npm:^1.0.0": + version: 1.0.0 + resolution: "tagged-tag@npm:1.0.0" + checksum: 10c0/91d25c9ffb86a91f20522cefb2cbec9b64caa1febe27ad0df52f08993ff60888022d771e868e6416cf2e72dab68449d2139e8709ba009b74c6c7ecd4000048d1 + languageName: node + linkType: hard + "tar-fs@npm:^2.0.0": version: 2.1.4 resolution: "tar-fs@npm:2.1.4" @@ -10494,23 +11081,23 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.15": - version: 0.2.16 - resolution: "tinyglobby@npm:0.2.16" +"tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.17": + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" dependencies: fdir: "npm:^6.5.0" picomatch: "npm:^4.0.4" - checksum: 10c0/f2e09fd93dd95c41e522113b686ff6f7c13020962f8698a864a257f3d7737599afc47722b7ab726e12f8a813f779906187911ff8ee6701ede65072671a7e934b + checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c languageName: node linkType: hard -"tinyglobby@npm:^0.2.17": - version: 0.2.17 - resolution: "tinyglobby@npm:0.2.17" +"tinyglobby@npm:^0.2.15": + version: 0.2.16 + resolution: "tinyglobby@npm:0.2.16" dependencies: fdir: "npm:^6.5.0" picomatch: "npm:^4.0.4" - checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c + checksum: 10c0/f2e09fd93dd95c41e522113b686ff6f7c13020962f8698a864a257f3d7737599afc47722b7ab726e12f8a813f779906187911ff8ee6701ede65072671a7e934b languageName: node linkType: hard @@ -10608,7 +11195,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -10671,6 +11258,15 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^5.8.0": + version: 5.9.0 + resolution: "type-fest@npm:5.9.0" + dependencies: + tagged-tag: "npm:^1.0.0" + checksum: 10c0/9bf14a49454b9b40d9fe85e563a4cf90990bbe7c75958665b72660b4197a905f247be682a7757cac7c8fa60fc0eae32cc8cf51f80a88e15f0a3d4a8ec07eb5b6 + languageName: node + linkType: hard + "type-is@npm:^2.0.1": version: 2.0.1 resolution: "type-is@npm:2.0.1" @@ -10814,6 +11410,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:>=7.24.0 <7.24.7": + version: 7.24.6 + resolution: "undici-types@npm:7.24.6" + checksum: 10c0/d9cd8befb643ac904615c280a095ba4240531f6bb4a5e75a22a7483630ca8d3f1016d2ab6ace6ceda1f63b3a2db2fe037fafe121d6917a0187573aa548ff78ca + languageName: node + linkType: hard + "undici-types@npm:~5.26.4": version: 5.26.5 resolution: "undici-types@npm:5.26.5" @@ -10828,13 +11431,6 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~7.19.0": - version: 7.19.2 - resolution: "undici-types@npm:7.19.2" - checksum: 10c0/7159f10546f9f6c47d36776bb1bbf8671e87c1e587a6fee84ae1f111ae8de4f914efa8ca0dfcd224f4f4a9dfc3f6028f627ccb5ddaccf82d7fd54671b89fac3e - languageName: node - linkType: hard - "unique-filename@npm:^4.0.0": version: 4.0.0 resolution: "unique-filename@npm:4.0.0" @@ -10904,6 +11500,37 @@ __metadata: languageName: node linkType: hard +"use-callback-ref@npm:^1.3.3": + version: 1.3.3 + resolution: "use-callback-ref@npm:1.3.3" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/f887488c6e6075cdad4962979da1714b217bcb1ee009a9e57ce9a844bcfc4c3a99e93983dfc2e5af9e0913824d24e730090ff255e902c516dcb58d2d3837e01c + languageName: node + linkType: hard + +"use-sidecar@npm:^1.1.3": + version: 1.1.3 + resolution: "use-sidecar@npm:1.1.3" + dependencies: + detect-node-es: "npm:^1.1.0" + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/161599bf921cfaa41c85d2b01c871975ee99260f3e874c2d41c05890d41170297bdcf314bc5185e7a700de2034ac5b888e3efc8e9f35724f4918f53538d717c9 + languageName: node + linkType: hard + "utif2@npm:^4.1.0": version: 4.1.0 resolution: "utif2@npm:4.1.0" @@ -10913,7 +11540,7 @@ __metadata: languageName: node linkType: hard -"util-deprecate@npm:^1.0.1, util-deprecate@npm:~1.0.1": +"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942 @@ -11048,6 +11675,39 @@ __metadata: languageName: node linkType: hard +"web2@workspace:web2": + version: 0.0.0-use.local + resolution: "web2@workspace:web2" + dependencies: + "@gfazioli/mantine-video": "npm:1.2.2" + "@mantine/carousel": "npm:9.6.1" + "@mantine/core": "npm:9.6.1" + "@mantine/dates": "npm:9.6.1" + "@mantine/form": "npm:9.6.1" + "@mantine/hooks": "npm:9.6.1" + "@mantine/lightbox": "npm:9.6.1" + "@mantine/modals": "npm:9.6.1" + "@mantine/notifications": "npm:9.6.1" + "@mantine/nprogress": "npm:9.6.1" + "@tabler/icons-react": "npm:3.46.0" + "@types/react": "npm:19.3.0" + "@types/react-dom": "npm:19.3.0" + "@vitejs/plugin-react": "npm:6.1.1" + dayjs: "npm:1.11.23" + embla-carousel: "npm:8.6.0" + embla-carousel-react: "npm:8.6.0" + hls.js: "npm:1.7.1" + postcss: "npm:8.5.26" + postcss-preset-mantine: "npm:1.18.0" + postcss-simple-vars: "npm:7.0.1" + react: "npm:19.3.0" + react-dom: "npm:19.3.0" + react-icons: "npm:5.7.0" + typescript: "npm:~6.0.3" + vite: "npm:8.0.16" + languageName: unknown + linkType: soft + "web@workspace:web": version: 0.0.0-use.local resolution: "web@workspace:web" @@ -11062,8 +11722,8 @@ __metadata: "@eslint/js": "npm:10.0.1" "@stylistic/eslint-plugin": "npm:5.10.0" "@types/google.maps": "npm:3.65.0" - "@types/react": "npm:19.2.17" - "@types/react-dom": "npm:19.2.3" + "@types/react": "npm:19.3.0" + "@types/react-dom": "npm:19.3.0" "@types/react-window": "npm:1.8.8" "@vitejs/plugin-react": "npm:6.0.2" eslint: "npm:10.3.0" @@ -11073,9 +11733,9 @@ __metadata: eslint-plugin-unused-imports: "npm:4.4.1" globals: "npm:17.6.0" hls.js: "npm:1.6.16" - react: "npm:19.2.8" - react-dom: "npm:19.2.8" - react-icons: "npm:5.6.0" + react: "npm:19.3.0" + react-dom: "npm:19.3.0" + react-icons: "npm:5.7.0" react-router-dom: "npm:7.15.0" react-window: "npm:1.8.11" socket.io-client: "npm:4.8.3"