Husr commited on
Commit
d988ae4
·
1 Parent(s): ea0f4ca

first commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +7 -0
  2. .gitignore +66 -0
  3. Dockerfile +40 -0
  4. README.md +75 -10
  5. apps/backend/package.json +47 -0
  6. apps/backend/pnpm-lock.yaml +0 -0
  7. apps/backend/src/admin/admin.controller.ts +41 -0
  8. apps/backend/src/admin/admin.guard.ts +23 -0
  9. apps/backend/src/admin/admin.module.ts +12 -0
  10. apps/backend/src/admin/admin.service.ts +54 -0
  11. apps/backend/src/app.module.ts +19 -0
  12. apps/backend/src/clipboard/clipboard.controller.ts +57 -0
  13. apps/backend/src/clipboard/clipboard.gateway.ts +287 -0
  14. apps/backend/src/clipboard/clipboard.module.ts +13 -0
  15. apps/backend/src/clipboard/clipboard.service.ts +386 -0
  16. apps/backend/src/common/filesystem/filesystem.controller.ts +43 -0
  17. apps/backend/src/common/filesystem/filesystem.module.ts +10 -0
  18. apps/backend/src/common/filesystem/filesystem.service.ts +82 -0
  19. apps/backend/src/common/filesystem/init-uploads.js +13 -0
  20. apps/backend/src/common/redis/redis.module.ts +9 -0
  21. apps/backend/src/common/redis/redis.service.ts +125 -0
  22. apps/backend/src/file/file.controller.ts +93 -0
  23. apps/backend/src/file/file.module.ts +12 -0
  24. apps/backend/src/file/file.service.ts +118 -0
  25. apps/backend/src/main.ts +50 -0
  26. apps/backend/tsconfig.json +24 -0
  27. apps/backend/types.d.ts +0 -0
  28. apps/frontend/next.config.mjs +19 -0
  29. apps/frontend/package.json +34 -0
  30. apps/frontend/pnpm-lock.yaml +0 -0
  31. apps/frontend/postcss.config.js +6 -0
  32. apps/frontend/public/logo.png +3 -0
  33. apps/frontend/src/app/[roomCode]/components/ClipboardEntry.tsx +66 -0
  34. apps/frontend/src/app/[roomCode]/components/ClipboardEntryForm.tsx +107 -0
  35. apps/frontend/src/app/[roomCode]/components/ClipboardEntryList.tsx +78 -0
  36. apps/frontend/src/app/[roomCode]/components/ClipboardHeader.tsx +97 -0
  37. apps/frontend/src/app/[roomCode]/components/FileEntry.ts +9 -0
  38. apps/frontend/src/app/[roomCode]/components/FileList.tsx +172 -0
  39. apps/frontend/src/app/[roomCode]/components/FilePreview.tsx +186 -0
  40. apps/frontend/src/app/[roomCode]/components/FileUploadComponent.tsx +156 -0
  41. apps/frontend/src/app/[roomCode]/components/LoadingState.tsx +64 -0
  42. apps/frontend/src/app/[roomCode]/components/PasswordVerificationModal.tsx +177 -0
  43. apps/frontend/src/app/[roomCode]/page.tsx +279 -0
  44. apps/frontend/src/app/admin/[roomCode]/page.tsx +339 -0
  45. apps/frontend/src/app/admin/page.tsx +201 -0
  46. apps/frontend/src/app/favicon.ico +0 -0
  47. apps/frontend/src/app/layout.tsx +68 -0
  48. apps/frontend/src/app/page.tsx +73 -0
  49. apps/frontend/src/assets/logo.png +3 -0
  50. apps/frontend/src/components/CreateClipboardCard.tsx +131 -0
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ apps/*/node_modules
3
+ .git
4
+ uploads
5
+ *.local
6
+ .pnpm-store
7
+ **/.next
.gitignore ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # === personal ===
2
+ deploy.sh
3
+ uploads/*
4
+
5
+ # Dependencies
6
+ node_modules
7
+ .pnp
8
+ .pnp.js
9
+ .pnpm-store/
10
+
11
+ # Testing
12
+ coverage
13
+ .nyc_output
14
+
15
+ # Build outputs
16
+ dist
17
+ build
18
+ .next
19
+ out
20
+ .nuxt
21
+ .output
22
+ .cache
23
+ .turbo
24
+
25
+ # Environment variables
26
+ .env
27
+ .env.local
28
+ .env.development.local
29
+ .env.test.local
30
+ .env.production.local
31
+
32
+ # Debug logs
33
+ npm-debug.log*
34
+ yarn-debug.log*
35
+ yarn-error.log*
36
+ pnpm-debug.log*
37
+ lerna-debug.log*
38
+
39
+ # Editor directories and files
40
+ .idea
41
+ .vscode/*
42
+ !.vscode/extensions.json
43
+ !.vscode/settings.json
44
+ *.suo
45
+ *.ntvs*
46
+ *.njsproj
47
+ *.sln
48
+ *.sw?
49
+ .DS_Store
50
+ *.pem
51
+
52
+ # Vercel
53
+ .vercel
54
+
55
+ # TypeScript
56
+ *.tsbuildinfo
57
+ next-env.d.ts
58
+
59
+ # Redis
60
+ dump.rdb
61
+ *.rdb
62
+
63
+ # Misc
64
+ .DS_Store
65
+ *.pem
66
+ .eslintcache
Dockerfile ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-bookworm
2
+
3
+ # Install runtime dependencies for the Space: nginx for proxying websockets and redis-server for storage
4
+ RUN apt-get update \
5
+ && apt-get install -y --no-install-recommends nginx redis-server gettext-base \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ WORKDIR /app
9
+
10
+ # Enable pnpm
11
+ ENV PNPM_HOME=/root/.local/share/pnpm
12
+ ENV PATH="$PNPM_HOME:$PATH"
13
+ RUN corepack enable && corepack prepare pnpm@10.11.0 --activate
14
+
15
+ # Copy manifests first for better caching
16
+ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
17
+ COPY apps/backend/package.json apps/backend/pnpm-lock.yaml ./apps/backend/
18
+ COPY apps/frontend/package.json apps/frontend/pnpm-lock.yaml ./apps/frontend/
19
+
20
+ # Install dependencies for all workspaces
21
+ RUN pnpm install --frozen-lockfile
22
+
23
+ # Copy the rest of the source
24
+ COPY . .
25
+
26
+ # Build the applications with sensible defaults for local communication
27
+ ENV NODE_ENV=production \
28
+ DOCKER_BACKEND_URL=http://127.0.0.1:3001 \
29
+ NEXT_PUBLIC_URL=http://127.0.0.1:7860 \
30
+ PUBLIC_SOCKET_URL=
31
+
32
+ RUN pnpm --filter @myclipboard.online/backend build \
33
+ && pnpm --filter @myclipboard.online/frontend build
34
+
35
+ # Clean up nginx defaults
36
+ RUN rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default || true
37
+
38
+ EXPOSE 7860
39
+
40
+ CMD ["bash", "deploy/hf/start.sh"]
README.md CHANGED
@@ -1,10 +1,75 @@
1
- ---
2
- title: Clip
3
- emoji: 👀
4
- colorFrom: indigo
5
- colorTo: purple
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MyClipboard.online
2
+
3
+ <p>
4
+ <img src="https://myclipboard.online/logo.png" alt="MyClipboard.online Logo" width="100">
5
+ </p>
6
+
7
+ A real-time shared clipboard application that allows users to easily share text snippets and files across devices.
8
+
9
+ **Live Demo**: [https://myclipboard.online](https://myclipboard.online)
10
+
11
+ ## 🚀 Features
12
+
13
+ - **Real-time Synchronization**: All clipboard entries are instantly synced across all connected devices
14
+ - **Room-based Sharing**: Create or join rooms with simple 4-character codes
15
+ - **Password Protection**: Optionally secure your clipboard rooms with passwords
16
+ - **File Sharing**: Upload and share files up to 10MB in size
17
+ - **Expiration**: Clipboards automatically expire after 24 hours of inactivity
18
+ - **User-friendly Interface**: Clean, responsive design that works on all devices
19
+ - **No Registration Required**: Start sharing instantly without creating an account
20
+
21
+ ## 🚀 Installation
22
+
23
+ Currently only docker is supported.
24
+
25
+ - Clone the repository
26
+ ```bash
27
+ git clone git@github.com:bilodev7/myclipboard.online.git
28
+
29
+ cd myclipboard.online
30
+ ```
31
+
32
+ - Start Development Server
33
+ ```bash
34
+ docker compose up
35
+ ```
36
+
37
+ - Start Production Server
38
+ ```bash
39
+ docker compose -f docker-compose.prod.yml up
40
+ ```
41
+
42
+ ## 🚀 Deploying to Hugging Face Spaces
43
+
44
+ The repository includes a `Dockerfile` tailored for the **Container** runtime in Hugging Face Spaces. It builds both the Next.js frontend and the NestJS backend, bundles an internal Redis instance, and uses Nginx to proxy Socket.IO traffic on the public port.
45
+
46
+ 1. Create a new **Container Space** and select this repository as the source.
47
+ 2. No custom `run` command is required—the Space will execute `deploy/hf/start.sh` defined in the Dockerfile.
48
+ 3. Optional environment variables (set them in **Settings → Variables and secrets** in your Space):
49
+ - `ADMIN_TOKEN` — token for the admin dashboard (defaults to `admin-token`).
50
+ - `PUBLIC_SOCKET_URL` — override the default of using the current origin for WebSocket connections.
51
+ - `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`, or `REDIS_URL` — point the app at an external Redis instance. When these are set, the Space skips starting the in-container Redis and will wait for the provided endpoint to be ready before launching the app.
52
+ - Example external Redis configuration (Zeabur):
53
+
54
+ ```
55
+ REDIS_HOST=<your-zeabur-host>
56
+ REDIS_PORT=<your-port>
57
+ REDIS_PASSWORD=<your-password>
58
+ ADMIN_TOKEN=<your-admin-token>
59
+ ```
60
+
61
+ 4. Files uploaded through the app are stored inside the Space container; if the Space restarts, uploads are cleared. For persistence, mount external storage.
62
+
63
+
64
+ ## 🛠️ Admin management
65
+
66
+ An admin surface is available to inspect and manage clipboards.
67
+
68
+ - **Authentication**: backend admin routes require the `ADMIN_TOKEN` environment variable (defaults to `admin-token` if unset). Supply the token via the `x-admin-token` header or a `Bearer` token.
69
+ - **API endpoints**:
70
+ - `GET /admin/clipboards` — list clipboards with entry/file counts, password flag, and TTL.
71
+ - `GET /admin/clipboards/:roomCode` — fetch clipboard details including files and TTL.
72
+ - `POST /admin/clipboards/:roomCode/password` — set or clear a room password with `{ "password": "new" }` or `{ "password": null }`.
73
+ - `POST /admin/clipboards/:roomCode/ttl` — set or clear expiration in seconds with `{ "ttl": 3600 }` or `{ "ttl": null }`.
74
+ - `DELETE /admin/clipboards/:roomCode` — delete a clipboard and its files.
75
+ - **Admin UI**: visit `/admin` in the frontend, enter the admin token once, then browse clipboards and perform updates from the dashboard and detail pages.
apps/backend/package.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@myclipboard.online/backend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "build": "nest build",
7
+ "format": "prettier --write \"src/**/*.ts\"",
8
+ "start": "nest start",
9
+ "dev": "nest start --watch",
10
+ "start:debug": "nest start --debug --watch",
11
+ "start:prod": "node dist/main",
12
+ "lint": "eslint \"{src,apps,libs}/**/*.ts\" --fix"
13
+ },
14
+ "dependencies": {
15
+ "@nestjs/common": "^10.2.7",
16
+ "@nestjs/core": "^10.2.7",
17
+ "@nestjs/platform-express": "^10.2.7",
18
+ "@nestjs/platform-socket.io": "^10.2.7",
19
+ "@nestjs/websockets": "^10.2.7",
20
+ "@types/mime-types": "^2.1.4",
21
+ "ioredis": "^5.3.2",
22
+ "mime-types": "^3.0.1",
23
+ "multer": "1.4.5-lts.2",
24
+ "redis": "^4.6.10",
25
+ "reflect-metadata": "^0.1.13",
26
+ "rxjs": "^7.8.1",
27
+ "socket.io": "^4.8.1",
28
+ "uuid": "^9.0.1"
29
+ },
30
+ "devDependencies": {
31
+ "@nestjs/cli": "^10.2.1",
32
+ "@nestjs/schematics": "^10.0.3",
33
+ "@types/express": "^4.17.20",
34
+ "@types/multer": "^1.4.9",
35
+ "@types/node": "^20.8.9",
36
+ "@types/uuid": "^9.0.6",
37
+ "@typescript-eslint/eslint-plugin": "^6.9.0",
38
+ "@typescript-eslint/parser": "^6.9.0",
39
+ "eslint": "^8.52.0",
40
+ "prettier": "^3.0.3",
41
+ "source-map-support": "^0.5.21",
42
+ "ts-loader": "^9.5.0",
43
+ "ts-node": "^10.9.1",
44
+ "tsconfig-paths": "^4.2.0",
45
+ "typescript": "^5.2.2"
46
+ }
47
+ }
apps/backend/pnpm-lock.yaml ADDED
The diff for this file is too large to render. See raw diff
 
apps/backend/src/admin/admin.controller.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
2
+ import { AdminService } from './admin.service';
3
+ import { AdminAuthGuard } from './admin.guard';
4
+
5
+ @Controller('admin')
6
+ @UseGuards(AdminAuthGuard)
7
+ export class AdminController {
8
+ constructor(private readonly adminService: AdminService) { }
9
+
10
+ @Get('clipboards')
11
+ listClipboards() {
12
+ return this.adminService.listClipboards();
13
+ }
14
+
15
+ @Get('clipboards/:roomCode')
16
+ getClipboard(@Param('roomCode') roomCode: string) {
17
+ return this.adminService.getClipboard(roomCode);
18
+ }
19
+
20
+ @Post('clipboards/:roomCode/password')
21
+ updatePassword(
22
+ @Param('roomCode') roomCode: string,
23
+ @Body() body: { password?: string | null },
24
+ ) {
25
+ return this.adminService.setPassword(roomCode, body.password);
26
+ }
27
+
28
+ @Post('clipboards/:roomCode/ttl')
29
+ updateTTL(
30
+ @Param('roomCode') roomCode: string,
31
+ @Body() body: { ttl?: number | null },
32
+ ) {
33
+ const ttl = body.ttl === null || body.ttl === undefined ? null : Number(body.ttl);
34
+ return this.adminService.setTTL(roomCode, isNaN(ttl) ? null : ttl);
35
+ }
36
+
37
+ @Delete('clipboards/:roomCode')
38
+ deleteClipboard(@Param('roomCode') roomCode: string) {
39
+ return this.adminService.deleteClipboard(roomCode);
40
+ }
41
+ }
apps/backend/src/admin/admin.guard.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
2
+ import { Observable } from 'rxjs';
3
+
4
+ @Injectable()
5
+ export class AdminAuthGuard implements CanActivate {
6
+ private readonly expectedToken = process.env.ADMIN_TOKEN || 'admin-token';
7
+
8
+ canActivate(
9
+ context: ExecutionContext,
10
+ ): boolean | Promise<boolean> | Observable<boolean> {
11
+ const request = context.switchToHttp().getRequest();
12
+ const headerToken = request.headers['x-admin-token'] || request.headers['authorization'];
13
+ const token = typeof headerToken === 'string' && headerToken.startsWith('Bearer ')
14
+ ? headerToken.slice(7)
15
+ : headerToken;
16
+
17
+ if (token === this.expectedToken) {
18
+ return true;
19
+ }
20
+
21
+ throw new UnauthorizedException('Invalid admin token');
22
+ }
23
+ }
apps/backend/src/admin/admin.module.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { AdminController } from './admin.controller';
3
+ import { AdminService } from './admin.service';
4
+ import { ClipboardModule } from '../clipboard/clipboard.module';
5
+ import { AdminAuthGuard } from './admin.guard';
6
+
7
+ @Module({
8
+ imports: [ClipboardModule],
9
+ controllers: [AdminController],
10
+ providers: [AdminService, AdminAuthGuard],
11
+ })
12
+ export class AdminModule {}
apps/backend/src/admin/admin.service.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable, Logger, NotFoundException } from '@nestjs/common';
2
+ import { ClipboardService } from '../clipboard/clipboard.service';
3
+
4
+ @Injectable()
5
+ export class AdminService {
6
+ private readonly logger = new Logger(AdminService.name);
7
+
8
+ constructor(private readonly clipboardService: ClipboardService) { }
9
+
10
+ async listClipboards() {
11
+ return this.clipboardService.listClipboards();
12
+ }
13
+
14
+ async getClipboard(roomCode: string) {
15
+ const clipboard = await this.clipboardService.getClipboard(roomCode);
16
+ const ttl = await this.clipboardService.getClipboardTTL(roomCode);
17
+
18
+ if (!clipboard) {
19
+ throw new NotFoundException(`Clipboard ${roomCode} not found`);
20
+ }
21
+
22
+ return { ...clipboard, ttl };
23
+ }
24
+
25
+ async setPassword(roomCode: string, password?: string | null) {
26
+ const clipboard = await this.clipboardService.updateClipboardPassword(roomCode, password);
27
+ if (!clipboard) {
28
+ throw new NotFoundException(`Clipboard ${roomCode} not found`);
29
+ }
30
+
31
+ this.logger.log(`Updated password for clipboard ${roomCode}`);
32
+ return clipboard;
33
+ }
34
+
35
+ async setTTL(roomCode: string, ttlSeconds: number | null) {
36
+ const updated = await this.clipboardService.setClipboardExpiration(roomCode, ttlSeconds);
37
+ if (!updated) {
38
+ throw new NotFoundException(`Clipboard ${roomCode} not found`);
39
+ }
40
+
41
+ this.logger.log(`Updated TTL for clipboard ${roomCode} -> ${ttlSeconds ?? 'none'}`);
42
+ return { success: true, ttl: ttlSeconds };
43
+ }
44
+
45
+ async deleteClipboard(roomCode: string) {
46
+ const success = await this.clipboardService.deleteClipboard(roomCode);
47
+ if (!success) {
48
+ throw new NotFoundException(`Clipboard ${roomCode} not found`);
49
+ }
50
+
51
+ this.logger.log(`Deleted clipboard ${roomCode}`);
52
+ return { success };
53
+ }
54
+ }
apps/backend/src/app.module.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { ClipboardModule } from './clipboard/clipboard.module';
3
+ import { RedisModule } from './common/redis/redis.module';
4
+ import { FileModule } from './file/file.module';
5
+ import { FilesystemModule } from './common/filesystem/filesystem.module';
6
+ import { AdminModule } from './admin/admin.module';
7
+
8
+ @Module({
9
+ imports: [
10
+ ClipboardModule,
11
+ RedisModule,
12
+ FileModule,
13
+ FilesystemModule,
14
+ AdminModule,
15
+ ],
16
+ controllers: [],
17
+ providers: [],
18
+ })
19
+ export class AppModule {}
apps/backend/src/clipboard/clipboard.controller.ts ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Controller, Post, Body, Get, Param, HttpException, HttpStatus } from '@nestjs/common';
2
+ import { ClipboardService } from './clipboard.service';
3
+
4
+ @Controller('clipboard')
5
+ export class ClipboardController {
6
+ constructor(private readonly clipboardService: ClipboardService) { }
7
+
8
+ @Post('create')
9
+ async createClipboard(@Body() body: { password?: string }) {
10
+ try {
11
+ const roomCode = await this.clipboardService.createClipboard(body.password);
12
+ return { roomCode };
13
+ } catch (error) {
14
+ throw new HttpException('Failed to create clipboard', HttpStatus.INTERNAL_SERVER_ERROR);
15
+ }
16
+ }
17
+
18
+ @Get(':roomCode/exists')
19
+ async clipboardExists(@Param('roomCode') roomCode: string) {
20
+ const exists = await this.clipboardService.clipboardExists(roomCode);
21
+
22
+ if (!exists) {
23
+ return { exists: false, hasPassword: false };
24
+ }
25
+
26
+ // Get the clipboard to check if it has a password
27
+ const clipboard = await this.clipboardService.getClipboard(roomCode);
28
+ const hasPassword = clipboard?.password ? true : false;
29
+
30
+ return { exists, hasPassword };
31
+ }
32
+
33
+ @Post(':roomCode/verify')
34
+ async verifyPassword(
35
+ @Param('roomCode') roomCode: string,
36
+ @Body() body: { password: string },
37
+ ) {
38
+ const isValid = await this.clipboardService.verifyPassword(roomCode, body.password);
39
+
40
+ if (!isValid) {
41
+ throw new HttpException('Invalid password', HttpStatus.UNAUTHORIZED);
42
+ }
43
+
44
+ return { success: true };
45
+ }
46
+
47
+ @Post(':roomCode/refresh')
48
+ async refreshExpiration(@Param('roomCode') roomCode: string) {
49
+ const success = await this.clipboardService.refreshExpiration(roomCode);
50
+
51
+ if (!success) {
52
+ throw new HttpException('Clipboard not found', HttpStatus.NOT_FOUND);
53
+ }
54
+
55
+ return { success };
56
+ }
57
+ }
apps/backend/src/clipboard/clipboard.gateway.ts ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ WebSocketGateway,
3
+ WebSocketServer,
4
+ SubscribeMessage,
5
+ OnGatewayConnection,
6
+ OnGatewayDisconnect,
7
+ OnGatewayInit,
8
+ ConnectedSocket,
9
+ MessageBody,
10
+ } from '@nestjs/websockets';
11
+ import { Server, Socket } from 'socket.io';
12
+ import { Logger } from '@nestjs/common';
13
+ import { ClipboardService, ClipboardEntry } from './clipboard.service';
14
+ import { FileEntry } from '../file/file.service';
15
+
16
+ interface RoomUserCount {
17
+ [roomCode: string]: number;
18
+ }
19
+
20
+ @WebSocketGateway({
21
+ cors: {
22
+ origin: '*',
23
+ methods: ['GET', 'POST'],
24
+ credentials: true
25
+ },
26
+ transports: ['websocket', 'polling'],
27
+ namespace: '/',
28
+ })
29
+ export class ClipboardGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
30
+ private readonly logger = new Logger(ClipboardGateway.name);
31
+ private roomUserCount: RoomUserCount = {};
32
+
33
+ @WebSocketServer()
34
+ server: Server;
35
+
36
+ constructor(private readonly clipboardService: ClipboardService) { }
37
+
38
+ afterInit(server: Server) {
39
+ this.logger.log('WebSocket Gateway initialized');
40
+ }
41
+
42
+ async handleConnection(client: Socket) {
43
+ const { roomCode } = client.handshake.query;
44
+
45
+ if (!roomCode || typeof roomCode !== 'string') {
46
+ this.logger.warn(`Client ${client.id} connected without a valid room code.`);
47
+ client.emit('error', { message: 'Room code is required.' });
48
+ client.disconnect();
49
+ return;
50
+ }
51
+
52
+ // Check if clipboard exists
53
+ const exists = await this.clipboardService.clipboardExists(roomCode);
54
+
55
+ if (!exists) {
56
+ this.logger.warn(`Client ${client.id} attempted to connect to non-existent room: ${roomCode}.`);
57
+ client.emit('error', { message: `Clipboard room ${roomCode} does not exist.` });
58
+ client.disconnect();
59
+ return;
60
+ }
61
+
62
+ this.logger.log(`Client connected: ${client.id} to room ${roomCode}`);
63
+ // Client will join the room upon receiving 'joinRoom' message from frontend
64
+ }
65
+
66
+ async handleDisconnect(client: Socket) {
67
+ const { roomCode } = client.handshake.query;
68
+
69
+ if (roomCode && typeof roomCode === 'string') {
70
+ // Remove client from room
71
+ client.leave(roomCode);
72
+
73
+ // Update user count
74
+ if (this.roomUserCount[roomCode]) {
75
+ this.roomUserCount[roomCode]--;
76
+
77
+ // Broadcast updated user count
78
+ this.server.to(roomCode).emit('userCount', this.roomUserCount[roomCode]);
79
+ }
80
+
81
+ this.logger.log(`Client disconnected: ${client.id} from room ${roomCode}`);
82
+ }
83
+ }
84
+
85
+ @SubscribeMessage('joinRoom')
86
+ async handleJoinRoom(
87
+ @ConnectedSocket() client: Socket,
88
+ @MessageBody() data: { roomCode: string; clientId: string },
89
+ ) {
90
+ const { roomCode, clientId } = data;
91
+ this.logger.log(`Attempting to join room: ${roomCode}, ClientID: ${clientId}, SocketID: ${client.id}`);
92
+
93
+ // Ensure client is connecting to the room specified in handshake
94
+ const handshakeRoomCode = client.handshake.query.roomCode;
95
+ if (handshakeRoomCode !== roomCode) {
96
+ this.logger.warn(`Client ${client.id} (clientId: ${clientId}) room mismatch. Handshake: ${handshakeRoomCode}, JoinRequest: ${roomCode}.`);
97
+ client.emit('error', { message: 'Room code mismatch. Please refresh.' });
98
+ client.disconnect(); // Disconnecting on critical mismatch
99
+ return;
100
+ }
101
+
102
+ this.logger.log(`Fetching clipboard for room ${roomCode}, ClientID: ${clientId}`);
103
+ const clipboard = await this.clipboardService.getClipboard(roomCode);
104
+
105
+ if (!clipboard) {
106
+ this.logger.warn(`Clipboard ${roomCode} not found for ClientID: ${clientId} during joinRoom.`);
107
+ client.emit('error', { message: `Clipboard ${roomCode} not found or has expired.` });
108
+ // Not disconnecting, client might go home or retry based on this error.
109
+ return;
110
+ }
111
+
112
+ this.logger.log(`Successfully fetched clipboard for room ${roomCode}, ClientID: ${clientId}. Joining socket to room.`);
113
+ client.join(roomCode);
114
+
115
+ if (!this.roomUserCount[roomCode]) {
116
+ this.roomUserCount[roomCode] = 0;
117
+ }
118
+ this.roomUserCount[roomCode]++;
119
+ this.logger.log(`User count for room ${roomCode} is now ${this.roomUserCount[roomCode]}.`);
120
+
121
+ const expiresIn = await this.clipboardService.getExpirationTime(roomCode);
122
+ this.logger.log(`Expiration for room ${roomCode} is ${expiresIn}. Emitting clipboardData to ClientID: ${clientId}.`);
123
+
124
+ client.emit('clipboardData', {
125
+ entries: clipboard.entries,
126
+ files: clipboard.files || [], // Include files array
127
+ connectedUsers: this.roomUserCount[roomCode],
128
+ expiresIn,
129
+ });
130
+
131
+ this.logger.log(`Emitting userCount to room ${roomCode}.`);
132
+ this.server.to(roomCode).emit('userCount', this.roomUserCount[roomCode]);
133
+
134
+ this.logger.log(`Client ${clientId} (SocketID: ${client.id}) successfully joined room ${roomCode}`);
135
+ }
136
+
137
+ @SubscribeMessage('addEntry')
138
+ async handleAddEntry(
139
+ @ConnectedSocket() client: Socket,
140
+ @MessageBody() data: { roomCode: string; content: string; clientId: string },
141
+ ) {
142
+ const { roomCode, content, clientId } = data;
143
+
144
+ // Verify client is in the correct room
145
+ if (!client.rooms.has(roomCode)) {
146
+ this.logger.warn(`Client ${client.id} (clientId: ${clientId}) attempted to add entry to ${roomCode} without being in it.`);
147
+ client.emit('error', { message: 'Not authorized for this room.' });
148
+ return;
149
+ }
150
+
151
+ this.logger.log(`Adding entry to room ${roomCode} from client ${clientId}`);
152
+
153
+ // Get all sockets in the room to verify room membership
154
+ const socketsInRoom = await this.server.in(roomCode).fetchSockets();
155
+ this.logger.log(`Number of clients in room ${roomCode}: ${socketsInRoom.length}`);
156
+
157
+ // Add entry to clipboard
158
+ const entry = await this.clipboardService.addEntry(roomCode, content, clientId);
159
+
160
+ if (!entry) {
161
+ this.logger.error(`Failed to add entry to room ${roomCode}`);
162
+ client.emit('error', { message: 'Failed to add entry' });
163
+ return;
164
+ }
165
+
166
+ // Broadcast new entry to all clients in the room
167
+ this.logger.log(`Broadcasting new entry to room ${roomCode}`);
168
+ this.server.to(roomCode).emit('newEntry', entry);
169
+
170
+ // Refresh expiration
171
+ await this.clipboardService.refreshExpiration(roomCode);
172
+
173
+ // Update expiration time
174
+ const expiresIn = await this.clipboardService.getExpirationTime(roomCode);
175
+ this.server.to(roomCode).emit('expirationUpdate', expiresIn);
176
+
177
+ this.logger.log(`New entry added to room ${roomCode}`);
178
+ }
179
+
180
+ @SubscribeMessage('deleteEntry')
181
+ async handleDeleteEntry(
182
+ @ConnectedSocket() client: Socket,
183
+ @MessageBody() data: { roomCode: string; entryId: string; clientId: string },
184
+ ) {
185
+ const { roomCode, entryId, clientId } = data;
186
+
187
+ // Verify client is in the correct room
188
+ if (!client.rooms.has(roomCode)) {
189
+ this.logger.warn(`Client ${client.id} (clientId: ${clientId}) attempted to delete entry from ${roomCode} without being in it.`);
190
+ client.emit('error', { message: 'Not authorized for this room.' });
191
+ return;
192
+ }
193
+
194
+ // Delete entry from clipboard
195
+ const success = await this.clipboardService.deleteEntry(roomCode, entryId);
196
+
197
+ if (!success) {
198
+ client.emit('error', { message: 'Failed to delete entry' });
199
+ return;
200
+ }
201
+
202
+ // Broadcast deleted entry to all clients in the room
203
+ this.server.to(roomCode).emit('deleteEntry', entryId);
204
+
205
+ // Refresh expiration
206
+ await this.clipboardService.refreshExpiration(roomCode);
207
+
208
+ this.logger.log(`Entry ${entryId} deleted from room ${roomCode}`);
209
+ }
210
+
211
+ @SubscribeMessage('clearClipboard')
212
+ async handleClearClipboard(
213
+ @ConnectedSocket() client: Socket,
214
+ @MessageBody() data: { roomCode: string; clientId: string },
215
+ ) {
216
+ const { roomCode, clientId } = data;
217
+
218
+ // Verify client is in the correct room
219
+ if (!client.rooms.has(roomCode)) {
220
+ this.logger.warn(`Client ${client.id} (clientId: ${clientId}) attempted to clear clipboard ${roomCode} without being in it.`);
221
+ client.emit('error', { message: 'Not authorized for this room.' });
222
+ return;
223
+ }
224
+
225
+ // Clear clipboard
226
+ const success = await this.clipboardService.clearClipboard(roomCode);
227
+
228
+ if (!success) {
229
+ client.emit('error', { message: 'Failed to clear clipboard' });
230
+ return;
231
+ }
232
+
233
+ // Broadcast clear event to all clients in the room
234
+ this.server.to(roomCode).emit('clipboardData', {
235
+ entries: [],
236
+ connectedUsers: this.roomUserCount[roomCode],
237
+ });
238
+
239
+ // Refresh expiration
240
+ await this.clipboardService.refreshExpiration(roomCode);
241
+
242
+ this.logger.log(`Clipboard ${roomCode} cleared by ${clientId}`);
243
+ }
244
+
245
+ @SubscribeMessage('fileUploaded')
246
+ async handleFileUploaded(
247
+ @ConnectedSocket() client: Socket,
248
+ @MessageBody() data: { roomCode: string; fileEntry: FileEntry; clientId: string },
249
+ ) {
250
+ const { roomCode, fileEntry, clientId } = data;
251
+
252
+ // Verify client is in the correct room
253
+ if (!client.rooms.has(roomCode)) {
254
+ this.logger.warn(`Client ${client.id} (clientId: ${clientId}) attempted to notify about file upload in ${roomCode} without being in it.`);
255
+ client.emit('error', { message: 'Not authorized for this room.' });
256
+ return;
257
+ }
258
+
259
+ // Broadcast file upload to all clients in the room
260
+ this.server.to(roomCode).emit('fileUploaded', fileEntry);
261
+
262
+ // Refresh expiration
263
+ await this.clipboardService.refreshExpiration(roomCode);
264
+
265
+ this.logger.log(`File ${fileEntry.id} uploaded to room ${roomCode}`);
266
+ }
267
+
268
+ @SubscribeMessage('deleteFile')
269
+ async handleDeleteFile(
270
+ @ConnectedSocket() client: Socket,
271
+ @MessageBody() data: { roomCode: string; fileId: string; clientId: string },
272
+ ) {
273
+ const { roomCode, fileId, clientId } = data;
274
+
275
+ // Verify client is in the correct room
276
+ if (!client.rooms.has(roomCode)) {
277
+ this.logger.warn(`Client ${client.id} (clientId: ${clientId}) attempted to delete file from ${roomCode} without being in it.`);
278
+ client.emit('error', { message: 'Not authorized for this room.' });
279
+ return;
280
+ }
281
+
282
+ // Broadcast file deletion to all clients in the room
283
+ this.server.to(roomCode).emit('fileDeleted', fileId);
284
+
285
+ this.logger.log(`File ${fileId} deleted from room ${roomCode}`);
286
+ }
287
+ }
apps/backend/src/clipboard/clipboard.module.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { ClipboardService } from './clipboard.service';
3
+ import { ClipboardGateway } from './clipboard.gateway';
4
+ import { ClipboardController } from './clipboard.controller';
5
+ import { FilesystemModule } from '../common/filesystem/filesystem.module';
6
+
7
+ @Module({
8
+ imports: [FilesystemModule],
9
+ providers: [ClipboardService, ClipboardGateway],
10
+ controllers: [ClipboardController],
11
+ exports: [ClipboardService],
12
+ })
13
+ export class ClipboardModule {}
apps/backend/src/clipboard/clipboard.service.ts ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import { RedisService } from '../common/redis/redis.service';
3
+ import { FilesystemService } from '../common/filesystem/filesystem.service';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+ import { FileEntry } from '../file/file.service';
6
+
7
+ export interface ClipboardEntry {
8
+ id: string;
9
+ content: string;
10
+ createdAt: string;
11
+ createdBy: string;
12
+ }
13
+
14
+ export interface Clipboard {
15
+ id: string;
16
+ entries: ClipboardEntry[];
17
+ files?: FileEntry[];
18
+ password?: string;
19
+ createdAt: string;
20
+ lastActivity: string;
21
+ }
22
+
23
+ @Injectable()
24
+ export class ClipboardService {
25
+ private readonly logger = new Logger(ClipboardService.name);
26
+ private readonly CLIPBOARD_PREFIX = 'clipboard:';
27
+ private readonly CLIPBOARD_INDEX_KEY = 'clipboard:index';
28
+
29
+ constructor(
30
+ private readonly redisService: RedisService,
31
+ private readonly filesystemService: FilesystemService,
32
+ ) {}
33
+
34
+ /**
35
+ * Generate a new room code for a clipboard
36
+ */
37
+ generateRoomCode(): string {
38
+ // Generate a 4-character alphanumeric code
39
+ return Math.random().toString(36).substring(2, 6).toUpperCase();
40
+ }
41
+
42
+ /**
43
+ * Create a new clipboard
44
+ */
45
+ async createClipboard(password?: string): Promise<string> {
46
+ let roomCode = this.generateRoomCode();
47
+ let exists = await this.clipboardExists(roomCode);
48
+
49
+ // Ensure we generate a unique room code
50
+ while (exists) {
51
+ roomCode = this.generateRoomCode();
52
+ exists = await this.clipboardExists(roomCode);
53
+ }
54
+
55
+ const now = new Date().toISOString();
56
+ const clipboard: Clipboard = {
57
+ id: roomCode,
58
+ entries: [],
59
+ password,
60
+ createdAt: now,
61
+ lastActivity: now,
62
+ };
63
+
64
+ await this.saveClipboard(roomCode, clipboard);
65
+ await this.addClipboardToIndex(roomCode);
66
+ return roomCode;
67
+ }
68
+
69
+ /**
70
+ * Create a new clipboard with a specific room code
71
+ */
72
+ async createClipboardWithCode(roomCode: string, password?: string): Promise<boolean> {
73
+ // Check if clipboard already exists
74
+ const exists = await this.clipboardExists(roomCode);
75
+ if (exists) {
76
+ return false; // Cannot create a clipboard that already exists
77
+ }
78
+
79
+ const now = new Date().toISOString();
80
+ const clipboard: Clipboard = {
81
+ id: roomCode,
82
+ entries: [],
83
+ password,
84
+ createdAt: now,
85
+ lastActivity: now,
86
+ };
87
+
88
+ await this.saveClipboard(roomCode, clipboard);
89
+ await this.addClipboardToIndex(roomCode);
90
+ return true;
91
+ }
92
+
93
+ /**
94
+ * Get a clipboard by room code
95
+ */
96
+ async getClipboard(roomCode: string): Promise<Clipboard | null> {
97
+ const key = this.getClipboardKey(roomCode);
98
+ const data = await this.redisService.get(key);
99
+
100
+ if (!data) {
101
+ return null;
102
+ }
103
+
104
+ return JSON.parse(data);
105
+ }
106
+
107
+ /**
108
+ * Check if a clipboard exists
109
+ */
110
+ async clipboardExists(roomCode: string): Promise<boolean> {
111
+ const key = this.getClipboardKey(roomCode);
112
+ return this.redisService.exists(key);
113
+ }
114
+
115
+ /**
116
+ * Verify clipboard password
117
+ */
118
+ async verifyPassword(roomCode: string, password: string): Promise<boolean> {
119
+ const clipboard = await this.getClipboard(roomCode);
120
+
121
+ if (!clipboard) {
122
+ return false;
123
+ }
124
+
125
+ // If no password is set, or passwords match
126
+ return !clipboard.password || clipboard.password === password;
127
+ }
128
+
129
+ /**
130
+ * Add an entry to a clipboard
131
+ */
132
+ async addEntry(roomCode: string, content: string, clientId: string): Promise<ClipboardEntry | null> {
133
+ const clipboard = await this.getClipboard(roomCode);
134
+
135
+ if (!clipboard) {
136
+ return null;
137
+ }
138
+
139
+ const entry: ClipboardEntry = {
140
+ id: uuidv4(),
141
+ content,
142
+ createdAt: new Date().toISOString(),
143
+ createdBy: clientId,
144
+ };
145
+
146
+ clipboard.entries.unshift(entry); // Add to the beginning of the array
147
+ clipboard.lastActivity = new Date().toISOString();
148
+
149
+ await this.saveClipboard(roomCode, clipboard);
150
+ return entry;
151
+ }
152
+
153
+ /**
154
+ * Delete an entry from a clipboard
155
+ */
156
+ async deleteEntry(roomCode: string, entryId: string): Promise<boolean> {
157
+ const clipboard = await this.getClipboard(roomCode);
158
+
159
+ if (!clipboard) {
160
+ return false;
161
+ }
162
+
163
+ const initialLength = clipboard.entries.length;
164
+ clipboard.entries = clipboard.entries.filter(entry => entry.id !== entryId);
165
+
166
+ if (clipboard.entries.length === initialLength) {
167
+ return false; // No entry was deleted
168
+ }
169
+
170
+ clipboard.lastActivity = new Date().toISOString();
171
+ await this.saveClipboard(roomCode, clipboard);
172
+ return true;
173
+ }
174
+
175
+ /**
176
+ * Clear all entries from a clipboard
177
+ */
178
+ async clearClipboard(roomCode: string): Promise<boolean> {
179
+ const clipboard = await this.getClipboard(roomCode);
180
+
181
+ if (!clipboard) {
182
+ return false;
183
+ }
184
+
185
+ clipboard.entries = [];
186
+ clipboard.lastActivity = new Date().toISOString();
187
+
188
+ await this.saveClipboard(roomCode, clipboard);
189
+ return true;
190
+ }
191
+
192
+ /**
193
+ * Get the time until clipboard expiration
194
+ */
195
+ async getExpirationTime(roomCode: string): Promise<string | null> {
196
+ const key = this.getClipboardKey(roomCode);
197
+ const ttl = await this.redisService.getTTL(key);
198
+
199
+ if (ttl <= 0) {
200
+ return null;
201
+ }
202
+
203
+ // Format the expiration time
204
+ if (ttl < 60 * 60) {
205
+ // Less than an hour
206
+ const minutes = Math.ceil(ttl / 60);
207
+ return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
208
+ } else {
209
+ // Hours
210
+ const hours = Math.ceil(ttl / (60 * 60));
211
+ return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
212
+ }
213
+ }
214
+
215
+ async getClipboardTTL(roomCode: string): Promise<number | null> {
216
+ const key = this.getClipboardKey(roomCode);
217
+ const ttl = await this.redisService.getTTL(key);
218
+
219
+ if (ttl <= 0) {
220
+ return null;
221
+ }
222
+
223
+ return ttl;
224
+ }
225
+
226
+ /**
227
+ * Refresh the expiration time for a clipboard
228
+ */
229
+ async refreshExpiration(roomCode: string): Promise<boolean> {
230
+ const key = this.getClipboardKey(roomCode);
231
+ const exists = await this.redisService.exists(key);
232
+
233
+ if (!exists) {
234
+ return false;
235
+ }
236
+
237
+ await this.redisService.refreshExpiry(key, null);
238
+ return true;
239
+ }
240
+
241
+ async setClipboardExpiration(
242
+ roomCode: string,
243
+ expiryInSeconds: number | null,
244
+ ): Promise<boolean> {
245
+ const key = this.getClipboardKey(roomCode);
246
+ const exists = await this.redisService.exists(key);
247
+
248
+ if (!exists) {
249
+ return false;
250
+ }
251
+
252
+ await this.redisService.refreshExpiry(key, expiryInSeconds);
253
+ return true;
254
+ }
255
+
256
+ /**
257
+ * Save a clipboard to Redis
258
+ */
259
+ async saveClipboard(
260
+ roomCode: string,
261
+ clipboard: Clipboard,
262
+ expiryInSeconds?: number | null,
263
+ ): Promise<void> {
264
+ const key = this.getClipboardKey(roomCode);
265
+ let ttlToUse: number | null = null;
266
+
267
+ if (expiryInSeconds !== undefined) {
268
+ ttlToUse = expiryInSeconds;
269
+ } else {
270
+ const currentTtl = await this.redisService.getTTL(key);
271
+ ttlToUse = currentTtl > 0 ? currentTtl : null;
272
+ }
273
+
274
+ await this.redisService.setWithExpiry(
275
+ key,
276
+ JSON.stringify(clipboard),
277
+ ttlToUse,
278
+ );
279
+ }
280
+
281
+ /**
282
+ * Get the Redis key for a clipboard
283
+ */
284
+ private getClipboardKey(roomCode: string): string {
285
+ return `${this.CLIPBOARD_PREFIX}${roomCode}`;
286
+ }
287
+
288
+ private async addClipboardToIndex(roomCode: string): Promise<void> {
289
+ await this.redisService.addToSet(this.CLIPBOARD_INDEX_KEY, roomCode);
290
+ }
291
+
292
+ private async removeClipboardFromIndex(roomCode: string): Promise<void> {
293
+ await this.redisService.removeFromSet(this.CLIPBOARD_INDEX_KEY, roomCode);
294
+ }
295
+
296
+ private async getIndexedClipboards(): Promise<string[]> {
297
+ const indexed = await this.redisService.getSetMembers(this.CLIPBOARD_INDEX_KEY);
298
+
299
+ if (indexed.length > 0) {
300
+ return indexed.map(id => this.getClipboardKey(id));
301
+ }
302
+
303
+ const scanned = await this.redisService.scanKeys(`${this.CLIPBOARD_PREFIX}*`);
304
+
305
+ for (const key of scanned) {
306
+ const id = key.replace(this.CLIPBOARD_PREFIX, '');
307
+ await this.addClipboardToIndex(id);
308
+ }
309
+
310
+ return scanned;
311
+ }
312
+
313
+ async listClipboards(): Promise<{
314
+ id: string;
315
+ createdAt: string;
316
+ lastActivity: string;
317
+ hasPassword: boolean;
318
+ entryCount: number;
319
+ fileCount: number;
320
+ ttl: number | null;
321
+ }[]> {
322
+ const keys = await this.getIndexedClipboards();
323
+
324
+ if (!keys.length) {
325
+ return [];
326
+ }
327
+
328
+ const values = await this.redisService.mget(keys);
329
+ const summaries = [];
330
+
331
+ for (let i = 0; i < keys.length; i++) {
332
+ const value = values[i];
333
+ if (!value) {
334
+ continue;
335
+ }
336
+
337
+ try {
338
+ const clipboard: Clipboard = JSON.parse(value);
339
+ const ttl = await this.getClipboardTTL(clipboard.id);
340
+ summaries.push({
341
+ id: clipboard.id,
342
+ createdAt: clipboard.createdAt,
343
+ lastActivity: clipboard.lastActivity,
344
+ hasPassword: Boolean(clipboard.password),
345
+ entryCount: clipboard.entries?.length || 0,
346
+ fileCount: clipboard.files?.length || 0,
347
+ ttl,
348
+ });
349
+ } catch (error) {
350
+ this.logger.error(`Failed to parse clipboard data for key ${keys[i]}`);
351
+ }
352
+ }
353
+
354
+ return summaries.sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime());
355
+ }
356
+
357
+ async updateClipboardPassword(roomCode: string, password?: string | null): Promise<Clipboard | null> {
358
+ const clipboard = await this.getClipboard(roomCode);
359
+
360
+ if (!clipboard) {
361
+ return null;
362
+ }
363
+
364
+ clipboard.password = password || undefined;
365
+ clipboard.lastActivity = new Date().toISOString();
366
+
367
+ await this.saveClipboard(roomCode, clipboard);
368
+ return clipboard;
369
+ }
370
+
371
+ async deleteClipboard(roomCode: string): Promise<boolean> {
372
+ const clipboard = await this.getClipboard(roomCode);
373
+ const key = this.getClipboardKey(roomCode);
374
+
375
+ if (!clipboard) {
376
+ return false;
377
+ }
378
+
379
+ await this.redisService.delete(key);
380
+ await this.removeClipboardFromIndex(roomCode);
381
+
382
+ await this.filesystemService.deleteRoomFiles(roomCode);
383
+ this.logger.log(`Deleted clipboard ${roomCode}`);
384
+ return true;
385
+ }
386
+ }
apps/backend/src/common/filesystem/filesystem.controller.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Controller, Get, Param, Res, Req, NotFoundException } from '@nestjs/common';
2
+ import { Request, Response } from 'express';
3
+ import { FilesystemService } from './filesystem.service';
4
+ import * as path from 'path';
5
+ import * as mime from 'mime-types';
6
+
7
+ @Controller('files')
8
+ export class FilesystemController {
9
+ constructor(private readonly filesystemService: FilesystemService) {}
10
+
11
+ @Get(':roomCode/:fileName')
12
+ async serveFile(
13
+ @Param('roomCode') roomCode: string,
14
+ @Param('fileName') fileName: string,
15
+ @Req() req: Request,
16
+ @Res() res: Response,
17
+ ) {
18
+ try {
19
+ const fileBuffer = await this.filesystemService.getFile(roomCode, fileName);
20
+
21
+ // Determine content type
22
+ const contentType = mime.lookup(fileName) || 'application/octet-stream';
23
+
24
+ // Set appropriate headers
25
+ res.setHeader('Content-Type', contentType);
26
+
27
+ // Get the original filename if provided in the query
28
+ const originalName = req.query.originalName ? decodeURIComponent(req.query.originalName.toString()) : fileName;
29
+
30
+ // Check if the request has a 'download' query parameter
31
+ const downloadHeader = req.query.download === 'true'
32
+ ? `attachment; filename="${originalName}"`
33
+ : `inline; filename="${originalName}"`;
34
+
35
+ res.setHeader('Content-Disposition', downloadHeader);
36
+
37
+ // Send the file
38
+ return res.send(fileBuffer);
39
+ } catch (error) {
40
+ throw new NotFoundException('File not found');
41
+ }
42
+ }
43
+ }
apps/backend/src/common/filesystem/filesystem.module.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { FilesystemService } from './filesystem.service';
3
+ import { FilesystemController } from './filesystem.controller';
4
+
5
+ @Module({
6
+ providers: [FilesystemService],
7
+ controllers: [FilesystemController],
8
+ exports: [FilesystemService],
9
+ })
10
+ export class FilesystemModule {}
apps/backend/src/common/filesystem/filesystem.service.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { promisify } from 'util';
5
+
6
+ const writeFileAsync = promisify(fs.writeFile);
7
+ const readFileAsync = promisify(fs.readFile);
8
+ const unlinkAsync = promisify(fs.unlink);
9
+ const mkdirAsync = promisify(fs.mkdir);
10
+ const existsAsync = promisify(fs.exists);
11
+
12
+ @Injectable()
13
+ export class FilesystemService {
14
+ private readonly logger = new Logger(FilesystemService.name);
15
+ private readonly uploadDir: string;
16
+
17
+ constructor() {
18
+ // Create uploads directory in the project root
19
+ this.uploadDir = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads');
20
+ this.initUploadDir().catch(err => {
21
+ this.logger.error(`Failed to initialize upload directory: ${err.message}`);
22
+ });
23
+ }
24
+
25
+ private async initUploadDir(): Promise<void> {
26
+ if (!await existsAsync(this.uploadDir)) {
27
+ await mkdirAsync(this.uploadDir, { recursive: true });
28
+ this.logger.log(`Upload directory '${this.uploadDir}' created successfully`);
29
+ }
30
+ }
31
+
32
+ async uploadFile(file: Express.Multer.File, roomCode: string, fileName: string): Promise<string> {
33
+ // Create room directory if it doesn't exist
34
+ const roomDir = path.join(this.uploadDir, roomCode);
35
+ if (!await existsAsync(roomDir)) {
36
+ await mkdirAsync(roomDir, { recursive: true });
37
+ }
38
+
39
+ // Save file to disk
40
+ const filePath = path.join(roomDir, fileName);
41
+ await writeFileAsync(filePath, file.buffer);
42
+
43
+ // Return the relative path to the file
44
+ return `/${roomCode}/${fileName}`;
45
+ }
46
+
47
+ async getFile(roomCode: string, fileName: string): Promise<Buffer> {
48
+ const filePath = path.join(this.uploadDir, roomCode, fileName);
49
+ return await readFileAsync(filePath);
50
+ }
51
+
52
+ async deleteFile(roomCode: string, fileName: string): Promise<boolean> {
53
+ try {
54
+ const filePath = path.join(this.uploadDir, roomCode, fileName);
55
+ await unlinkAsync(filePath);
56
+ return true;
57
+ } catch (error) {
58
+ this.logger.error(`Failed to delete file: ${error.message}`);
59
+ return false;
60
+ }
61
+ }
62
+
63
+ async deleteRoomFiles(roomCode: string): Promise<boolean> {
64
+ try {
65
+ const roomDir = path.join(this.uploadDir, roomCode);
66
+ if (await existsAsync(roomDir)) {
67
+ // Delete all files in the directory
68
+ const files = fs.readdirSync(roomDir);
69
+ for (const file of files) {
70
+ await unlinkAsync(path.join(roomDir, file));
71
+ }
72
+
73
+ // Remove the directory itself
74
+ fs.rmdirSync(roomDir);
75
+ }
76
+ return true;
77
+ } catch (error) {
78
+ this.logger.error(`Failed to delete room files: ${error.message}`);
79
+ return false;
80
+ }
81
+ }
82
+ }
apps/backend/src/common/filesystem/init-uploads.js ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ // Create uploads directory in the project root
6
+ const uploadDir = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads');
7
+
8
+ if (!fs.existsSync(uploadDir)) {
9
+ fs.mkdirSync(uploadDir, { recursive: true });
10
+ console.log(`Upload directory '${uploadDir}' created successfully`);
11
+ } else {
12
+ console.log(`Upload directory '${uploadDir}' already exists`);
13
+ }
apps/backend/src/common/redis/redis.module.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { Module, Global } from '@nestjs/common';
2
+ import { RedisService } from './redis.service';
3
+
4
+ @Global()
5
+ @Module({
6
+ providers: [RedisService],
7
+ exports: [RedisService],
8
+ })
9
+ export class RedisModule {}
apps/backend/src/common/redis/redis.service.ts ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
2
+ import { Redis } from 'ioredis';
3
+
4
+ @Injectable()
5
+ export class RedisService implements OnModuleInit, OnModuleDestroy {
6
+ private readonly logger = new Logger(RedisService.name);
7
+ private redisClient: Redis;
8
+
9
+ constructor() {
10
+ // Initialize Redis client
11
+ // Prefer REDIS_URL if provided, otherwise fall back to host/port/password envs
12
+ const redisUrl = process.env.REDIS_URL;
13
+
14
+ if (redisUrl) {
15
+ const url = new URL(redisUrl);
16
+
17
+ this.redisClient = new Redis({
18
+ host: url.hostname,
19
+ port: Number(url.port) || 6379,
20
+ password: url.password || process.env.REDIS_PASSWORD,
21
+ db: url.pathname ? Number(url.pathname.replace('/', '')) || 0 : 0,
22
+ ...(url.protocol === 'rediss:' ? { tls: {} } : {}),
23
+ });
24
+ return;
25
+ }
26
+
27
+ const redisHost = process.env.REDIS_HOST || 'localhost';
28
+ const redisPort = parseInt(process.env.REDIS_PORT || '6379', 10);
29
+
30
+ this.redisClient = new Redis({
31
+ host: redisHost,
32
+ port: redisPort,
33
+ password: process.env.REDIS_PASSWORD,
34
+ });
35
+ }
36
+
37
+ async onModuleInit() {
38
+ try {
39
+ await this.redisClient.ping();
40
+ this.logger.log('Successfully connected to Redis');
41
+ } catch (error) {
42
+ this.logger.error('Failed to connect to Redis', error);
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ async onModuleDestroy() {
48
+ await this.redisClient.quit();
49
+ this.logger.log('Redis connection closed');
50
+ }
51
+
52
+ getClient(): Redis {
53
+ return this.redisClient;
54
+ }
55
+
56
+ // Helper methods for clipboard operations
57
+
58
+ async setWithExpiry(key: string, value: string, expiryInSeconds?: number | null): Promise<void> {
59
+ if (expiryInSeconds && expiryInSeconds > 0) {
60
+ await this.redisClient.set(key, value, 'EX', expiryInSeconds);
61
+ return;
62
+ }
63
+
64
+ await this.redisClient.set(key, value);
65
+ }
66
+
67
+ async get(key: string): Promise<string | null> {
68
+ return this.redisClient.get(key);
69
+ }
70
+
71
+ async delete(key: string): Promise<void> {
72
+ await this.redisClient.del(key);
73
+ }
74
+
75
+ async exists(key: string): Promise<boolean> {
76
+ const result = await this.redisClient.exists(key);
77
+ return result === 1;
78
+ }
79
+
80
+ async getTTL(key: string): Promise<number> {
81
+ return this.redisClient.ttl(key);
82
+ }
83
+
84
+ async refreshExpiry(key: string, expiryInSeconds?: number | null): Promise<void> {
85
+ if (expiryInSeconds && expiryInSeconds > 0) {
86
+ await this.redisClient.expire(key, expiryInSeconds);
87
+ return;
88
+ }
89
+
90
+ // Remove expiration to make the key persistent
91
+ await this.redisClient.persist(key);
92
+ }
93
+
94
+ async addToSet(key: string, value: string): Promise<void> {
95
+ await this.redisClient.sadd(key, value);
96
+ }
97
+
98
+ async removeFromSet(key: string, value: string): Promise<void> {
99
+ await this.redisClient.srem(key, value);
100
+ }
101
+
102
+ async getSetMembers(key: string): Promise<string[]> {
103
+ return this.redisClient.smembers(key);
104
+ }
105
+
106
+ async mget(keys: string[]): Promise<(string | null)[]> {
107
+ if (!keys.length) {
108
+ return [];
109
+ }
110
+ return this.redisClient.mget(...keys);
111
+ }
112
+
113
+ async scanKeys(pattern: string): Promise<string[]> {
114
+ const keys: string[] = [];
115
+ let cursor = '0';
116
+
117
+ do {
118
+ const [nextCursor, results] = await this.redisClient.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
119
+ cursor = nextCursor;
120
+ keys.push(...results);
121
+ } while (cursor !== '0');
122
+
123
+ return keys;
124
+ }
125
+ }
apps/backend/src/file/file.controller.ts ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Controller,
3
+ Post,
4
+ Get,
5
+ Delete,
6
+ Param,
7
+ UploadedFile,
8
+ UseInterceptors,
9
+ Body,
10
+ Res,
11
+ MaxFileSizeValidator,
12
+ ParseFilePipe,
13
+ HttpStatus,
14
+ HttpException,
15
+ } from '@nestjs/common';
16
+ import { FileInterceptor } from '@nestjs/platform-express';
17
+ import { Response } from 'express';
18
+ import { FileService } from './file.service';
19
+
20
+ @Controller('clipboard/:roomCode/files')
21
+ export class FileController {
22
+ constructor(private readonly fileService: FileService) {}
23
+
24
+ @Post()
25
+ @UseInterceptors(FileInterceptor('file'))
26
+ async uploadFile(
27
+ @Param('roomCode') roomCode: string,
28
+ @UploadedFile(
29
+ new ParseFilePipe({
30
+ validators: [
31
+ new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }), // 10MB
32
+ ],
33
+ }),
34
+ )
35
+ file: Express.Multer.File,
36
+ @Body('clientId') clientId: string,
37
+ ) {
38
+ try {
39
+ const fileEntry = await this.fileService.uploadFile(
40
+ roomCode,
41
+ file,
42
+ clientId,
43
+ );
44
+ return fileEntry;
45
+ } catch (error) {
46
+ throw new HttpException(
47
+ error.message || 'Failed to upload file',
48
+ HttpStatus.INTERNAL_SERVER_ERROR,
49
+ );
50
+ }
51
+ }
52
+
53
+ @Get(':fileId')
54
+ async getFile(
55
+ @Param('roomCode') roomCode: string,
56
+ @Param('fileId') fileId: string,
57
+ @Res() res: Response,
58
+ ) {
59
+ try {
60
+ const fileData = await this.fileService.getFileData(roomCode, fileId);
61
+ const fileUrl = fileData.url;
62
+
63
+ // If there's a filename parameter in the query, append it to the redirect URL
64
+ const originalFilename = res.req.query.filename;
65
+ const redirectUrl = originalFilename
66
+ ? `${fileUrl}?download=true&originalName=${encodeURIComponent(originalFilename.toString())}`
67
+ : fileUrl;
68
+
69
+ return res.redirect(redirectUrl);
70
+ } catch (error) {
71
+ throw new HttpException(
72
+ error.message || 'Failed to get file',
73
+ HttpStatus.NOT_FOUND,
74
+ );
75
+ }
76
+ }
77
+
78
+ @Delete(':fileId')
79
+ async deleteFile(
80
+ @Param('roomCode') roomCode: string,
81
+ @Param('fileId') fileId: string,
82
+ ) {
83
+ try {
84
+ const success = await this.fileService.deleteFile(roomCode, fileId);
85
+ return { success };
86
+ } catch (error) {
87
+ throw new HttpException(
88
+ error.message || 'Failed to delete file',
89
+ HttpStatus.INTERNAL_SERVER_ERROR,
90
+ );
91
+ }
92
+ }
93
+ }
apps/backend/src/file/file.module.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { FileController } from './file.controller';
3
+ import { FileService } from './file.service';
4
+ import { FilesystemModule } from '../common/filesystem/filesystem.module';
5
+ import { ClipboardModule } from '../clipboard/clipboard.module';
6
+
7
+ @Module({
8
+ imports: [FilesystemModule, ClipboardModule],
9
+ controllers: [FileController],
10
+ providers: [FileService],
11
+ })
12
+ export class FileModule {}
apps/backend/src/file/file.service.ts ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable, Logger, NotFoundException } from '@nestjs/common';
2
+ import { FilesystemService } from '../common/filesystem/filesystem.service';
3
+ import { ClipboardService } from '../clipboard/clipboard.service';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+
6
+ export interface FileEntry {
7
+ id: string;
8
+ filename: string;
9
+ mimetype: string;
10
+ size: number;
11
+ storageKey: string;
12
+ createdAt: string;
13
+ createdBy: string;
14
+ }
15
+
16
+ @Injectable()
17
+ export class FileService {
18
+ private readonly logger = new Logger(FileService.name);
19
+
20
+ constructor(
21
+ private readonly filesystemService: FilesystemService,
22
+ private readonly clipboardService: ClipboardService,
23
+ ) {}
24
+
25
+ async uploadFile(
26
+ roomCode: string,
27
+ file: Express.Multer.File,
28
+ clientId: string,
29
+ ): Promise<FileEntry> {
30
+ // Check if clipboard exists
31
+ const clipboard = await this.clipboardService.getClipboard(roomCode);
32
+ if (!clipboard) {
33
+ throw new NotFoundException(`Clipboard ${roomCode} not found`);
34
+ }
35
+
36
+ // Generate unique ID for file
37
+ const fileId = uuidv4();
38
+ const fileName = `${fileId}-${file.originalname}`;
39
+
40
+ // Upload file to filesystem
41
+ await this.filesystemService.uploadFile(file, roomCode, fileName);
42
+
43
+ // Create file entry
44
+ const fileEntry: FileEntry = {
45
+ id: fileId,
46
+ filename: file.originalname,
47
+ mimetype: file.mimetype,
48
+ size: file.size,
49
+ storageKey: `${roomCode}/${fileName}`,
50
+ createdAt: new Date().toISOString(),
51
+ createdBy: clientId,
52
+ };
53
+
54
+ // Add file to clipboard
55
+ if (!clipboard.files) {
56
+ clipboard.files = [];
57
+ }
58
+ clipboard.files.unshift(fileEntry);
59
+ clipboard.lastActivity = new Date().toISOString();
60
+
61
+ // Save updated clipboard
62
+ await this.clipboardService.saveClipboard(roomCode, clipboard);
63
+
64
+ return fileEntry;
65
+ }
66
+
67
+ async getFileUrl(roomCode: string, fileId: string): Promise<string> {
68
+ const fileData = await this.getFileData(roomCode, fileId);
69
+ return fileData.url;
70
+ }
71
+
72
+ async getFileData(roomCode: string, fileId: string): Promise<{ url: string; filename: string; fileEntry: FileEntry }> {
73
+ const clipboard = await this.clipboardService.getClipboard(roomCode);
74
+ if (!clipboard || !clipboard.files) {
75
+ throw new NotFoundException(`Clipboard ${roomCode} not found`);
76
+ }
77
+
78
+ const fileEntry = clipboard.files.find(file => file.id === fileId);
79
+ if (!fileEntry) {
80
+ throw new NotFoundException(`File ${fileId} not found`);
81
+ }
82
+
83
+ // Return the URL to the file controller with the correct API path
84
+ const [roomCodeFromPath, fileName] = fileEntry.storageKey.split('/');
85
+ return {
86
+ url: `/api/files/${roomCodeFromPath}/${fileName}`,
87
+ filename: fileEntry.filename,
88
+ fileEntry
89
+ };
90
+ }
91
+
92
+ async deleteFile(roomCode: string, fileId: string): Promise<boolean> {
93
+ const clipboard = await this.clipboardService.getClipboard(roomCode);
94
+ if (!clipboard || !clipboard.files) {
95
+ throw new NotFoundException(`Clipboard ${roomCode} not found`);
96
+ }
97
+
98
+ const fileIndex = clipboard.files.findIndex(file => file.id === fileId);
99
+ if (fileIndex === -1) {
100
+ throw new NotFoundException(`File ${fileId} not found`);
101
+ }
102
+
103
+ const fileEntry = clipboard.files[fileIndex];
104
+
105
+ // Delete file from filesystem
106
+ const [roomCodeFromPath, fileName] = fileEntry.storageKey.split('/');
107
+ await this.filesystemService.deleteFile(roomCodeFromPath, fileName);
108
+
109
+ // Remove file entry from clipboard
110
+ clipboard.files.splice(fileIndex, 1);
111
+ clipboard.lastActivity = new Date().toISOString();
112
+
113
+ // Save updated clipboard
114
+ await this.clipboardService.saveClipboard(roomCode, clipboard);
115
+
116
+ return true;
117
+ }
118
+ }
apps/backend/src/main.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NestFactory } from '@nestjs/core';
2
+ import { AppModule } from './app.module';
3
+ import { Logger } from '@nestjs/common';
4
+ import { IoAdapter } from '@nestjs/platform-socket.io';
5
+ import { NextFunction, Request } from 'express';
6
+
7
+ class CustomIoAdapter extends IoAdapter {
8
+ createIOServer(port: number, options?: any): any {
9
+ const server = super.createIOServer(port, {
10
+ ...options,
11
+ cors: {
12
+ origin: '*',
13
+ methods: ['GET', 'POST'],
14
+ credentials: true,
15
+ },
16
+ allowEIO3: true,
17
+ transports: ['websocket', 'polling'],
18
+ });
19
+ return server;
20
+ }
21
+ }
22
+
23
+ async function bootstrap() {
24
+ const logger = new Logger('Bootstrap');
25
+ const app = await NestFactory.create(AppModule);
26
+
27
+ // Enable CORS for HTTP requests
28
+ app.enableCors({
29
+ origin: '*',
30
+ methods: ['GET', 'POST', 'DELETE', 'PUT', 'PATCH', 'OPTIONS'],
31
+ credentials: true,
32
+ });
33
+ app.use("*", (req: Request, _, next: NextFunction) => {
34
+ console.log(req.method, req.baseUrl)
35
+ next()
36
+ })
37
+
38
+ // Use custom adapter for WebSockets
39
+ app.useWebSocketAdapter(new CustomIoAdapter(app));
40
+
41
+ // Set global prefix for REST API
42
+ app.setGlobalPrefix('api');
43
+
44
+
45
+ const port = process.env.PORT || 3001;
46
+ await app.listen(port);
47
+ logger.log(`Application listening on port ${port}`);
48
+ }
49
+
50
+ bootstrap();
apps/backend/tsconfig.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "module": "commonjs",
4
+ "declaration": true,
5
+ "removeComments": true,
6
+ "emitDecoratorMetadata": true,
7
+ "experimentalDecorators": true,
8
+ "allowSyntheticDefaultImports": true,
9
+ "target": "es2017",
10
+ "sourceMap": true,
11
+ "outDir": "./dist",
12
+ "baseUrl": "./",
13
+ "incremental": true,
14
+ "skipLibCheck": true,
15
+ "strictNullChecks": false,
16
+ "noImplicitAny": false,
17
+ "strictBindCallApply": false,
18
+ "forceConsistentCasingInFileNames": false,
19
+ "noFallthroughCasesInSwitch": false,
20
+ "paths": {
21
+ "@/*": ["src/*"]
22
+ }
23
+ }
24
+ }
apps/backend/types.d.ts ADDED
File without changes
apps/frontend/next.config.mjs ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ reactStrictMode: true,
4
+ env: {
5
+ NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL,
6
+ PUBLIC_SOCKET_URL: process.env.PUBLIC_SOCKET_URL
7
+ },
8
+ async rewrites() {
9
+ const backendUrl = process.env.DOCKER_BACKEND_URL || 'http://127.0.0.1:3001';
10
+ return [
11
+ {
12
+ source: '/api/:path*',
13
+ destination: `${backendUrl}/api/:path*` // Backend is co-located with the frontend in the Space image
14
+ },
15
+ ];
16
+ },
17
+ };
18
+
19
+ export default nextConfig;
apps/frontend/package.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@myclipboard.online/frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint"
10
+ },
11
+ "dependencies": {
12
+ "axios": "^1.6.0",
13
+ "clsx": "^2.1.1",
14
+ "lucide-react": "^0.511.0",
15
+ "next": "^14.0.0",
16
+ "react": "^18.2.0",
17
+ "react-dom": "^18.2.0",
18
+ "react-dropzone": "^14.3.8",
19
+ "react-otp-input": "^3.1.1",
20
+ "socket.io-client": "^4.7.2",
21
+ "tailwind-merge": "^3.3.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^20.8.9",
25
+ "@types/react": "^18.2.33",
26
+ "@types/react-dom": "^18.2.14",
27
+ "autoprefixer": "^10.4.16",
28
+ "eslint": "^8.52.0",
29
+ "eslint-config-next": "^14.0.0",
30
+ "postcss": "^8.4.31",
31
+ "tailwindcss": "^3.3.5",
32
+ "typescript": "^5.2.2"
33
+ }
34
+ }
apps/frontend/pnpm-lock.yaml ADDED
The diff for this file is too large to render. See raw diff
 
apps/frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
apps/frontend/public/logo.png ADDED

Git LFS Details

  • SHA256: 84f5631077805390494824892423c65a902f97f3853ac67e91a4573440803347
  • Pointer size: 132 Bytes
  • Size of remote file: 1.1 MB
apps/frontend/src/app/[roomCode]/components/ClipboardEntry.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { CopyCheck, CopyCheckIcon, CopyIcon } from 'lucide-react';
4
+ import { useState } from 'react';
5
+
6
+ export interface ClipboardEntryType {
7
+ id: string;
8
+ content: string;
9
+ createdAt: string;
10
+ createdBy: string;
11
+ }
12
+
13
+ interface ClipboardEntryProps {
14
+ entry: ClipboardEntryType;
15
+ onCopy: (content: string, id: string) => void;
16
+ onDelete: (id: string) => void;
17
+ isCopied: boolean;
18
+ }
19
+
20
+ export default function ClipboardEntry({ entry, onCopy, onDelete, isCopied }: ClipboardEntryProps) {
21
+ const formatDate = (dateString: string) => {
22
+ const date = new Date(dateString);
23
+ return date.toLocaleString();
24
+ };
25
+
26
+ return (
27
+ <div className="clipboard-entry group hover:border-primary/30 hover:shadow-glow-sm p-3 sm:p-4 rounded-lg border border-surface-hover bg-surface/50 backdrop-blur-sm transition-all duration-200">
28
+ <div className="flex justify-between items-center mb-2 sm:mb-3">
29
+ <div className="flex items-center space-x-2">
30
+ <button
31
+ onClick={() => onCopy(entry.content, entry.id)}
32
+ className="mr-auto flex items-center justify-center h-7 min-w-7 rounded-full bg-surface hover:bg-surface-hover border border-surface-hover text-text-primary hover:text-primary transition-colors"
33
+ title="Copy to clipboard"
34
+ >
35
+ {isCopied ? (
36
+ <div className='text-primary px-2'>COPIED</div>
37
+ ) : (
38
+ <CopyIcon className='size-4' />
39
+ )}
40
+ </button>
41
+ <div className="flex items-center text-text-secondary text-xs">
42
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
43
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
44
+ </svg>
45
+ <span className="hidden xs:inline">{formatDate(entry.createdAt)}</span>
46
+ <span className="xs:hidden">{new Date(entry.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
47
+ </div>
48
+ </div>
49
+
50
+ <button
51
+ onClick={() => onDelete(entry.id)}
52
+ className="flex items-center justify-center w-7 h-7 rounded-full bg-surface hover:bg-red-500/10 border border-surface-hover text-text-primary hover:text-red-500 transition-colors"
53
+ title="Delete entry"
54
+ >
55
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
56
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
57
+ </svg>
58
+ </button>
59
+
60
+ </div>
61
+ <div className="clipboard-entry-content overflow-x-auto whitespace-pre-wrap break-words text-sm sm:text-base">
62
+ {entry.content}
63
+ </div>
64
+ </div>
65
+ );
66
+ }
apps/frontend/src/app/[roomCode]/components/ClipboardEntryForm.tsx ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+
5
+ interface ClipboardEntryFormProps {
6
+ onAddEntry: (content: string) => void;
7
+ }
8
+
9
+ export default function ClipboardEntryForm({ onAddEntry }: ClipboardEntryFormProps) {
10
+ const [newEntry, setNewEntry] = useState('');
11
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
12
+
13
+ // Auto-resize textarea as content grows, but with a maximum height
14
+ useEffect(() => {
15
+ const textarea = textareaRef.current;
16
+ if (textarea) {
17
+ textarea.style.height = 'auto';
18
+ const newHeight = Math.min(textarea.scrollHeight, 300); // Maximum height of 300px
19
+ textarea.style.height = `${newHeight}px`;
20
+ }
21
+ }, [newEntry]);
22
+
23
+ const handleSubmit = (e: React.FormEvent) => {
24
+ e.preventDefault();
25
+
26
+ if (!newEntry.trim()) return;
27
+
28
+ onAddEntry(newEntry);
29
+ setNewEntry('');
30
+
31
+ // Focus back on textarea after submit
32
+ setTimeout(() => {
33
+ if (textareaRef.current) {
34
+ textareaRef.current.focus();
35
+ }
36
+ }, 0);
37
+ };
38
+
39
+ const handleKeyDown = (e: React.KeyboardEvent) => {
40
+ // Submit on Ctrl+Enter
41
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
42
+ handleSubmit(e);
43
+ }
44
+ };
45
+
46
+ return (
47
+ <div className="mb-6 sm:mb-8 relative">
48
+ <div className="flex flex-wrap items-center justify-between gap-2 mb-3">
49
+ <h2 className="text-lg font-semibold text-text-primary flex items-center">
50
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
51
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
52
+ </svg>
53
+ Add Text
54
+ </h2>
55
+ </div>
56
+
57
+ <form onSubmit={handleSubmit} className="space-y-2">
58
+ <div className="relative group">
59
+ <div className="absolute -inset-0.5 bg-gradient-to-r from-primary/20 to-secondary/20 rounded-lg blur opacity-0 group-hover:opacity-100 transition duration-500"></div>
60
+ <div className="relative">
61
+ <div className="flex focus-within:ring-2 focus-within:ring-primary/30 focus-within:rounded-lg">
62
+ <textarea
63
+ ref={textareaRef}
64
+ value={newEntry}
65
+ onChange={(e) => setNewEntry(e.target.value)}
66
+ onKeyDown={handleKeyDown}
67
+ placeholder="Type or paste text to share..."
68
+ className="w-full px-4 py-3 min-h-[100px] max-h-[300px] rounded-l-lg bg-surface border-r-0 border-surface-hover focus:outline-none transition-colors text-text-primary placeholder-text-secondary/50 resize-none overflow-y-auto pb-8"
69
+ />
70
+ <div className="flex flex-col">
71
+ <button
72
+ type="button"
73
+ onClick={() => {
74
+ navigator.clipboard.readText().then(text => {
75
+ if (text) {
76
+ setNewEntry(prev => prev + text);
77
+ }
78
+ }).catch(err => console.error('Failed to read clipboard contents: ', err));
79
+ }}
80
+ className="flex items-center justify-center h-1/2 px-3 bg-secondary hover:bg-secondary/90 text-white border border-surface-hover border-l-0 rounded-tr-lg transition-all duration-200 ease-in-out hover:shadow-glow-sm focus:outline-none"
81
+ title="Paste from clipboard"
82
+ >
83
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
84
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
85
+ </svg>
86
+ </button>
87
+ <button
88
+ type="submit"
89
+ disabled={!newEntry.trim()}
90
+ className="flex items-center justify-center h-1/2 px-3 bg-primary hover:bg-primary/90 text-white border border-t-0 border-l-0 border-surface-hover rounded-br-lg transition-all duration-200 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed hover:shadow-glow-sm focus:outline-none"
91
+ title="Add to clipboard"
92
+ >
93
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
94
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
95
+ </svg>
96
+ </button>
97
+ </div>
98
+ </div>
99
+ <div className="absolute bottom-2 right-14 text-xs px-1.5 py-0.5 bg-surface-active/90 backdrop-blur-sm rounded text-text-secondary z-10">
100
+ Ctrl+Enter to submit
101
+ </div>
102
+ </div>
103
+ </div>
104
+ </form>
105
+ </div>
106
+ );
107
+ }
apps/frontend/src/app/[roomCode]/components/ClipboardEntryList.tsx ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import ClipboardEntry, { ClipboardEntryType } from './ClipboardEntry';
5
+
6
+ interface ClipboardEntryListProps {
7
+ entries: ClipboardEntryType[];
8
+ onCopyEntry: (content: string, id: string) => void;
9
+ onDeleteEntry: (id: string) => void;
10
+ onClearAll: () => void;
11
+ copiedId: string | null;
12
+ }
13
+
14
+ export default function ClipboardEntryList({
15
+ entries,
16
+ copiedId,
17
+ onCopyEntry,
18
+ onDeleteEntry,
19
+ onClearAll
20
+ }: ClipboardEntryListProps) {
21
+ const [showClearConfirm, setShowClearConfirm] = useState(false);
22
+
23
+ const handleClearRequest = () => {
24
+ if (showClearConfirm) {
25
+ onClearAll();
26
+ setShowClearConfirm(false);
27
+ } else {
28
+ setShowClearConfirm(true);
29
+ setTimeout(() => setShowClearConfirm(false), 3000);
30
+ }
31
+ };
32
+
33
+ return (
34
+ <div className="mt-6 sm:mt-8">
35
+ <div className="flex items-center justify-between mb-4 sticky top-0 bg-background/90 backdrop-blur-sm py-2 z-10">
36
+ <div className="flex-1"></div>
37
+ <div className="flex items-center gap-2">
38
+ {entries.length > 0 && (
39
+ <button
40
+ onClick={handleClearRequest}
41
+ className={`flex items-center px-1.5 py-1 rounded-lg transition-all duration-200 text-xs ${showClearConfirm ? 'bg-red-500/90 text-white' : 'bg-surface hover:bg-surface-hover text-text-secondary hover:text-red-500'} border border-surface-hover`}
42
+ title="Clear all clipboard entries"
43
+ >
44
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
45
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
46
+ </svg>
47
+ {showClearConfirm ? 'Confirm' : 'Clear'}
48
+ </button>
49
+ )}
50
+ </div>
51
+ </div>
52
+
53
+ <div className="space-y-4 px-1">
54
+ {entries.length === 0 ? (
55
+ <div className="clipboard-entry border-dashed flex flex-col items-center justify-center py-12 mt-4">
56
+ <div className="w-16 h-16 bg-surface-hover rounded-full flex items-center justify-center mb-4">
57
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-text-secondary/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
58
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
59
+ </svg>
60
+ </div>
61
+ <p className="text-text-secondary mb-2">No entries yet</p>
62
+ <p className="text-text-secondary/70 text-sm">Add your first entry using the form above</p>
63
+ </div>
64
+ ) : (
65
+ entries.map((entry) => (
66
+ <ClipboardEntry
67
+ key={entry.id}
68
+ entry={entry}
69
+ onCopy={onCopyEntry}
70
+ onDelete={onDeleteEntry}
71
+ isCopied={copiedId === entry.id}
72
+ />
73
+ ))
74
+ )}
75
+ </div>
76
+ </div>
77
+ );
78
+ }
apps/frontend/src/app/[roomCode]/components/ClipboardHeader.tsx ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import Link from 'next/link';
5
+ import { ClipboardIcon } from 'lucide-react';
6
+ import Logo from '@/components/Logo';
7
+
8
+ interface ClipboardHeaderProps {
9
+ roomCode: string;
10
+ connectedUsers: number;
11
+ expiresIn: string | null;
12
+ onCopyLink: () => void;
13
+ }
14
+
15
+ export default function ClipboardHeader({
16
+ roomCode,
17
+ connectedUsers,
18
+ expiresIn,
19
+ onCopyLink
20
+ }: ClipboardHeaderProps) {
21
+ const [linkCopied, setLinkCopied] = useState(false);
22
+
23
+ const handleCopyLink = () => {
24
+ onCopyLink();
25
+ setLinkCopied(true);
26
+ setTimeout(() => setLinkCopied(false), 2000);
27
+ };
28
+
29
+ return (
30
+ <header className="bg-background/80 backdrop-blur-sm border-b border-surface-hover py-2 sm:py-3 md:py-4 sticky top-0 z-20">
31
+ <div className="container mx-auto px-3 sm:px-4 max-w-4xl">
32
+ <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-1 sm:gap-2">
33
+ <div className="flex flex-wrap items-center gap-2">
34
+ <Link href="/">
35
+ <button className="flex items-center justify-center w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-surface hover:bg-surface-hover border border-surface-hover transition-all duration-200 shadow-sm hover:shadow-md" title="Back to home">
36
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
37
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
38
+ </svg>
39
+ </button>
40
+ </Link>
41
+ <h1 className="text-xl sm:text-2xl font-bold flex items-center">
42
+ <button
43
+ onClick={handleCopyLink}
44
+ className={`text-primary font-mono px-2 py-2 rounded flex items-center ${linkCopied ? 'bg-primary/20' : 'hover:bg-primary/10'} transition-colors duration-200`}
45
+ title="Click to copy room link"
46
+ >
47
+ <span className="leading-none -mb-1">{roomCode.substring(0, 2)}-{roomCode.substring(2)}</span>
48
+ {linkCopied && (
49
+ <span className="ml-2 text-xs bg-primary/20 text-primary px-1.5 py-0.5 rounded inline-flex items-center">Copied!</span>
50
+ )}
51
+ </button>
52
+ </h1>
53
+ </div>
54
+
55
+ <div className="flex items-center gap-2 mt-1 sm:mt-2">
56
+ <div className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-primary/10 text-primary rounded-full text-xs flex items-center">
57
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-0.5 sm:mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
58
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
59
+ </svg>
60
+ <span>{connectedUsers} <span className="hidden sm:inline">{connectedUsers === 1 ? 'user' : 'users'}</span></span>
61
+ </div>
62
+
63
+ <div className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-emerald-500/10 text-emerald-500 rounded-full text-xs flex items-center">
64
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-0.5 sm:mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
65
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
66
+ </svg>
67
+ <span className="hidden sm:inline">End-to-End Encrypted</span>
68
+ <span className="sm:hidden">E2E</span>
69
+ </div>
70
+
71
+ <div className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-amber-500/10 text-amber-500 rounded-full text-xs flex items-center">
72
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-0.5 sm:mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
73
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
74
+ </svg>
75
+ {expiresIn ? (
76
+ <span>Expires in {expiresIn}</span>
77
+ ) : (
78
+ <>
79
+ <span className="hidden sm:inline">Never expires</span>
80
+ <span className="sm:hidden">No expiry</span>
81
+ </>
82
+ )}
83
+ </div>
84
+
85
+ <div className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-primary/10 text-primary rounded-full text-xs flex items-center">
86
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-0.5 sm:mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
87
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
88
+ </svg>
89
+ <span>Real-time sync</span>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ </div>
95
+ </header>
96
+ );
97
+ }
apps/frontend/src/app/[roomCode]/components/FileEntry.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ export interface FileEntryType {
2
+ id: string;
3
+ filename: string;
4
+ mimetype: string;
5
+ size: number;
6
+ storageKey: string;
7
+ createdAt: string;
8
+ createdBy: string;
9
+ }
apps/frontend/src/app/[roomCode]/components/FileList.tsx ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { FileIcon, Image, FileText, Download, Trash2, Eye, AlertCircle } from 'lucide-react';
5
+ import { apiUrl } from '@/lib/constants';
6
+ import FilePreview from './FilePreview';
7
+ import { isPreviewable, formatFileSize } from '@/lib/utils/fileUtils';
8
+
9
+ interface FileEntry {
10
+ id: string;
11
+ filename: string;
12
+ mimetype: string;
13
+ size: number;
14
+ createdAt: string;
15
+ createdBy: string;
16
+ }
17
+
18
+ interface FileListProps {
19
+ files: FileEntry[];
20
+ onDeleteFile: (fileId: string) => void;
21
+ roomCode: string;
22
+ }
23
+
24
+ export default function FileList({ files, onDeleteFile, roomCode }: FileListProps) {
25
+ const [deletingFile, setDeletingFile] = useState<string | null>(null);
26
+ const [previewFile, setPreviewFile] = useState<FileEntry | null>(null);
27
+
28
+ // Clear preview if the previewed file is deleted
29
+ useEffect(() => {
30
+ if (previewFile && !files.some(file => file.id === previewFile.id)) {
31
+ setPreviewFile(null);
32
+ }
33
+ }, [files, previewFile]);
34
+
35
+ if (files.length === 0) {
36
+ return null;
37
+ }
38
+
39
+ const getFileIcon = (mimetype: string) => {
40
+ if (mimetype.startsWith('image/')) {
41
+ return <Image className="h-5 w-5 text-blue-500" />;
42
+ } else if (mimetype.includes('pdf')) {
43
+ return <FileText className="h-5 w-5 text-red-500" />;
44
+ } else {
45
+ return <FileIcon className="h-5 w-5 text-gray-500" />;
46
+ }
47
+ };
48
+
49
+ // Check if a file is previewable
50
+ const canPreview = (file: FileEntry) => {
51
+ return isPreviewable(file.mimetype);
52
+ };
53
+
54
+ return (
55
+ <div className="mt-6 sm:mt-8">
56
+ <div className="flex items-center justify-between mb-4 sticky top-0 bg-background/90 backdrop-blur-sm py-2 z-10">
57
+ <div className="flex-1"></div>
58
+ <div className="flex items-center gap-2">
59
+ </div>
60
+ </div>
61
+
62
+ {previewFile && (
63
+ <FilePreview
64
+ fileId={previewFile.id}
65
+ filename={previewFile.filename}
66
+ mimetype={previewFile.mimetype}
67
+ roomCode={roomCode}
68
+ onClose={() => setPreviewFile(null)}
69
+ />
70
+ )}
71
+
72
+ <div className="space-y-2">
73
+ {files.length === 0 ? (
74
+ <div className="border border-dashed border-surface-hover rounded-lg flex flex-col items-center justify-center py-12 mt-4">
75
+ <div className="w-16 h-16 bg-surface-hover rounded-full flex items-center justify-center mb-4">
76
+ <FileIcon className="h-8 w-8 text-text-secondary/50" />
77
+ </div>
78
+ <p className="text-text-secondary mb-2">No files yet</p>
79
+ <p className="text-text-secondary/70 text-sm">Upload your first file using the form above</p>
80
+ </div>
81
+ ) : (
82
+ files.map((file) => (
83
+ <div
84
+ key={file.id}
85
+ className={`flex items-center p-3 bg-surface/80 rounded-lg border border-surface-hover hover:border-primary/30 transition-colors relative ${canPreview(file) ? 'cursor-pointer' : ''}`}
86
+ onClick={() => canPreview(file) ? setPreviewFile(file) : null}
87
+ >
88
+ <div className="mr-3">
89
+ {getFileIcon(file.mimetype)}
90
+ </div>
91
+ <div className="flex-grow min-w-0">
92
+ <p className="text-text-primary font-medium truncate">{file.filename}</p>
93
+ <p className="text-text-tertiary text-xs">
94
+ {formatFileSize(file.size)} • {new Date(file.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
95
+ </p>
96
+ </div>
97
+ <div className="flex space-x-2" onClick={(e) => e.stopPropagation()}>
98
+ {canPreview(file) ? (
99
+ <button
100
+ onClick={() => setPreviewFile(file)}
101
+ className="p-1.5 text-text-secondary hover:text-primary rounded-md hover:bg-primary/10 transition-colors"
102
+ title="Preview"
103
+ >
104
+ <Eye className="h-4 w-4" />
105
+ </button>
106
+ ) : (
107
+ <span
108
+ className="p-1.5 text-text-tertiary cursor-not-allowed flex items-center"
109
+ title="Preview not available for this file type"
110
+ >
111
+ <AlertCircle className="h-4 w-4" />
112
+ </span>
113
+ )}
114
+ <a
115
+ onClick={(e) => e.stopPropagation()}
116
+ href={`${apiUrl}/clipboard/${roomCode}/files/${file.id}?filename=${encodeURIComponent(file.filename)}`}
117
+ download={file.filename}
118
+ className="p-1.5 text-text-secondary hover:text-primary rounded-md hover:bg-primary/10 transition-colors"
119
+ title="Download"
120
+ >
121
+ <Download className="h-4 w-4" />
122
+ </a>
123
+ <button
124
+ onClick={(e) => {
125
+ e.stopPropagation();
126
+ setDeletingFile(file.id)
127
+ }}
128
+ className="p-1.5 text-text-secondary hover:text-error rounded-md hover:bg-error/10 transition-colors"
129
+ title="Delete"
130
+ >
131
+ <Trash2 className="h-4 w-4" />
132
+ </button>
133
+ </div>
134
+
135
+ {/* Delete confirmation */}
136
+ {deletingFile === file.id && (
137
+ <div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg">
138
+ <div className="bg-surface p-4 rounded-lg shadow-lg max-w-xs w-full">
139
+ <h4 className="text-text-primary font-medium mb-2">Delete file?</h4>
140
+ <p className="text-text-secondary text-sm mb-4">This action cannot be undone.</p>
141
+ <div className="flex justify-end space-x-2">
142
+ <button
143
+ onClick={() => setDeletingFile(null)}
144
+ className="px-3 py-1.5 text-text-secondary hover:text-text-primary bg-surface-hover rounded-md"
145
+ >
146
+ Cancel
147
+ </button>
148
+ <button
149
+ onClick={() => {
150
+ // If this file is being previewed, close the preview first
151
+ if (previewFile && previewFile.id === file.id) {
152
+ setPreviewFile(null);
153
+ }
154
+ // Then delete the file
155
+ onDeleteFile(file.id);
156
+ setDeletingFile(null);
157
+ }}
158
+ className="px-3 py-1.5 text-white bg-error hover:bg-error/90 rounded-md"
159
+ >
160
+ Delete
161
+ </button>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ )}
166
+ </div>
167
+ ))
168
+ )}
169
+ </div>
170
+ </div>
171
+ );
172
+ }
apps/frontend/src/app/[roomCode]/components/FilePreview.tsx ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { X } from 'lucide-react';
5
+ import { apiUrl } from '@/lib/constants';
6
+
7
+ interface FilePreviewProps {
8
+ fileId: string;
9
+ filename: string;
10
+ mimetype: string;
11
+ roomCode: string;
12
+ onClose: () => void;
13
+ }
14
+
15
+ export default function FilePreview({ fileId, filename, mimetype, roomCode, onClose }: FilePreviewProps) {
16
+ const [loading, setLoading] = useState(true);
17
+ const fileUrl = `${apiUrl}/clipboard/${roomCode}/files/${fileId}`;
18
+
19
+ // Handle escape key to close preview
20
+ useEffect(() => {
21
+ const handleEsc = (event: KeyboardEvent) => {
22
+ if (event.key === 'Escape') {
23
+ onClose();
24
+ }
25
+ };
26
+ window.addEventListener('keydown', handleEsc);
27
+ return () => {
28
+ window.removeEventListener('keydown', handleEsc);
29
+ };
30
+ }, [onClose]);
31
+
32
+ const renderPreview = () => {
33
+ if (mimetype.startsWith('image/')) {
34
+ return (
35
+ <div className="flex items-center justify-center h-full">
36
+ <img
37
+ src={fileUrl}
38
+ alt={filename}
39
+ className="max-w-full max-h-full object-contain"
40
+ onLoad={() => setLoading(false)}
41
+ />
42
+ </div>
43
+ );
44
+ } else if (mimetype === 'application/pdf') {
45
+ return (
46
+ <iframe
47
+ src={`${fileUrl}#toolbar=0`}
48
+ className="w-full h-full"
49
+ title={filename}
50
+ onLoad={() => setLoading(false)}
51
+ />
52
+ );
53
+ } else if (mimetype.startsWith('text/') || mimetype === 'application/json') {
54
+ return <TextFilePreview fileUrl={fileUrl} setLoading={setLoading} />;
55
+ } else if (mimetype.startsWith('video/')) {
56
+ return (
57
+ <video
58
+ controls
59
+ className="max-w-full max-h-full"
60
+ onLoadedData={() => setLoading(false)}
61
+ >
62
+ <source src={fileUrl} type={mimetype} />
63
+ Your browser does not support the video tag.
64
+ </video>
65
+ );
66
+ } else if (mimetype.startsWith('audio/')) {
67
+ return (
68
+ <div className="flex flex-col items-center justify-center h-full">
69
+ <div className="bg-surface/80 p-6 rounded-lg shadow-lg">
70
+ <p className="text-text-primary text-lg mb-4 text-center">{filename}</p>
71
+ <audio
72
+ controls
73
+ className="w-full"
74
+ onLoadedData={() => setLoading(false)}
75
+ >
76
+ <source src={fileUrl} type={mimetype} />
77
+ Your browser does not support the audio element.
78
+ </audio>
79
+ </div>
80
+ </div>
81
+ );
82
+ } else {
83
+ // For unsupported file types
84
+ setLoading(false);
85
+ return (
86
+ <div className="flex flex-col items-center justify-center h-full">
87
+ <div className="bg-surface/80 p-8 rounded-lg shadow-lg text-center">
88
+ <p className="text-text-primary text-xl mb-4">Preview not available</p>
89
+ <p className="text-text-secondary mb-6">This file type cannot be previewed.</p>
90
+ <a
91
+ href={`${apiUrl}/clipboard/${roomCode}/files/${fileId}?filename=${encodeURIComponent(filename)}`}
92
+ target="_blank"
93
+ rel="noopener noreferrer"
94
+ className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors"
95
+ download={filename}
96
+ >
97
+ Download File
98
+ </a>
99
+ </div>
100
+ </div>
101
+ );
102
+ }
103
+ };
104
+
105
+ return (
106
+ <div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
107
+ <div className="bg-background rounded-lg shadow-xl w-full max-w-4xl h-[80vh] flex flex-col">
108
+ {/* Header */}
109
+ <div className="flex items-center justify-between p-4 border-b border-surface-hover">
110
+ <h3 className="text-lg font-medium text-text-primary truncate max-w-[calc(100%-40px)]">
111
+ {filename}
112
+ </h3>
113
+ <button
114
+ onClick={onClose}
115
+ className="p-1 rounded-full hover:bg-surface-hover transition-colors"
116
+ aria-label="Close preview"
117
+ >
118
+ <X className="h-5 w-5 text-text-secondary" />
119
+ </button>
120
+ </div>
121
+
122
+ {/* Content */}
123
+ <div className="flex-1 overflow-auto relative">
124
+ {loading && (
125
+ <div className="absolute inset-0 flex items-center justify-center bg-background/80">
126
+ <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
127
+ </div>
128
+ )}
129
+ {renderPreview()}
130
+ </div>
131
+
132
+ {/* Footer */}
133
+ <div className="p-4 border-t border-surface-hover flex justify-end">
134
+ <a
135
+ href={`${apiUrl}/clipboard/${roomCode}/files/${fileId}?filename=${encodeURIComponent(filename)}`}
136
+ download={filename}
137
+ className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors"
138
+ >
139
+ Download
140
+ </a>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ );
145
+ }
146
+
147
+ // Component to handle text file preview
148
+ function TextFilePreview({ fileUrl, setLoading }: { fileUrl: string; setLoading: (loading: boolean) => void }) {
149
+ const [content, setContent] = useState<string>('');
150
+ const [error, setError] = useState<string | null>(null);
151
+
152
+ useEffect(() => {
153
+ async function fetchTextContent() {
154
+ try {
155
+ const response = await fetch(fileUrl);
156
+ if (!response.ok) {
157
+ throw new Error(`Failed to fetch file: ${response.status}`);
158
+ }
159
+ const text = await response.text();
160
+ setContent(text);
161
+ } catch (err) {
162
+ setError(err instanceof Error ? err.message : 'Failed to load file');
163
+ } finally {
164
+ setLoading(false);
165
+ }
166
+ }
167
+
168
+ fetchTextContent();
169
+ }, [fileUrl, setLoading]);
170
+
171
+ if (error) {
172
+ return (
173
+ <div className="flex items-center justify-center h-full">
174
+ <div className="bg-error/10 text-error p-4 rounded-md">
175
+ {error}
176
+ </div>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ return (
182
+ <pre className="p-4 whitespace-pre-wrap break-words text-text-primary font-mono text-sm overflow-auto h-full bg-surface/30">
183
+ {content}
184
+ </pre>
185
+ );
186
+ }
apps/frontend/src/app/[roomCode]/components/FileUploadComponent.tsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useRef } from 'react';
4
+ import { Upload, X } from 'lucide-react';
5
+ import { apiUrl } from '@/lib/constants';
6
+
7
+ interface FileUploadProps {
8
+ roomCode: string;
9
+ clientId: string;
10
+ onFileUploaded: (fileEntry: any) => void;
11
+ }
12
+
13
+ export default function FileUploadComponent({ roomCode, clientId, onFileUploaded }: FileUploadProps) {
14
+ const [isDragging, setIsDragging] = useState(false);
15
+ const [isUploading, setIsUploading] = useState(false);
16
+ const [uploadProgress, setUploadProgress] = useState(0);
17
+ const [error, setError] = useState<string | null>(null);
18
+ const fileInputRef = useRef<HTMLInputElement>(null);
19
+
20
+ const handleDragOver = (e: React.DragEvent) => {
21
+ e.preventDefault();
22
+ setIsDragging(true);
23
+ };
24
+
25
+ const handleDragLeave = () => {
26
+ setIsDragging(false);
27
+ };
28
+
29
+ const handleDrop = (e: React.DragEvent) => {
30
+ e.preventDefault();
31
+ setIsDragging(false);
32
+
33
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
34
+ handleFileUpload(e.dataTransfer.files[0]);
35
+ }
36
+ };
37
+
38
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
39
+ if (e.target.files && e.target.files.length > 0) {
40
+ handleFileUpload(e.target.files[0]);
41
+ }
42
+ };
43
+
44
+ const handleFileUpload = async (file: File) => {
45
+ // Validate file size (10MB max)
46
+ if (file.size > 10 * 1024 * 1024) {
47
+ setError('File size exceeds 10MB limit');
48
+ return;
49
+ }
50
+
51
+ setIsUploading(true);
52
+ setUploadProgress(0);
53
+ setError(null);
54
+
55
+ const formData = new FormData();
56
+ formData.append('file', file);
57
+ formData.append('clientId', clientId);
58
+
59
+ try {
60
+ const xhr = new XMLHttpRequest();
61
+
62
+ xhr.upload.onprogress = (event) => {
63
+ if (event.lengthComputable) {
64
+ const progress = Math.round((event.loaded / event.total) * 100);
65
+ setUploadProgress(progress);
66
+ }
67
+ };
68
+
69
+ xhr.onload = () => {
70
+ if (xhr.status === 200 || xhr.status === 201) {
71
+ const response = JSON.parse(xhr.responseText);
72
+ onFileUploaded(response);
73
+ setIsUploading(false);
74
+ setUploadProgress(0);
75
+
76
+ // Reset file input
77
+ if (fileInputRef.current) {
78
+ fileInputRef.current.value = '';
79
+ }
80
+ } else {
81
+ setError('Upload failed');
82
+ setIsUploading(false);
83
+ }
84
+ };
85
+
86
+ xhr.onerror = () => {
87
+ setError('Network error occurred');
88
+ setIsUploading(false);
89
+ };
90
+
91
+ xhr.open('POST', `${apiUrl}/clipboard/${roomCode}/files`, true);
92
+ xhr.send(formData);
93
+
94
+ } catch (err: any) {
95
+ console.error('Error uploading file:', err);
96
+ setError(err.message || 'An unexpected error occurred');
97
+ setIsUploading(false);
98
+ }
99
+ };
100
+
101
+ return (
102
+ <div className="mb-6 sm:mb-8 relative">
103
+ <div className="flex flex-wrap items-center justify-between gap-2 mb-3">
104
+ <h2 className="text-lg font-semibold text-text-primary flex items-center">
105
+ <Upload className="h-5 w-5 mr-2 text-secondary" />
106
+ Add File
107
+ </h2>
108
+ </div>
109
+ <div
110
+ className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
111
+ isDragging
112
+ ? 'border-primary bg-primary/5'
113
+ : 'border-surface-hover hover:border-primary/50 hover:bg-surface/80'
114
+ }`}
115
+ onDragOver={handleDragOver}
116
+ onDragLeave={handleDragLeave}
117
+ onDrop={handleDrop}
118
+ onClick={() => fileInputRef.current?.click()}
119
+ >
120
+ <input
121
+ type="file"
122
+ ref={fileInputRef}
123
+ onChange={handleFileChange}
124
+ className="hidden"
125
+ />
126
+
127
+ <div className="flex flex-col items-center justify-center py-3">
128
+ <Upload className="h-8 w-8 text-primary mb-2" />
129
+ <p className="text-text-secondary mb-1">
130
+ {isDragging ? 'Drop file here' : 'Click or drag file to upload'}
131
+ </p>
132
+ <p className="text-text-tertiary text-xs">Max size: 10MB</p>
133
+ </div>
134
+ </div>
135
+
136
+ {isUploading && (
137
+ <div className="mt-2">
138
+ <div className="w-full bg-surface-hover rounded-full h-2 mt-1">
139
+ <div
140
+ className="bg-primary h-2 rounded-full transition-all duration-300"
141
+ style={{ width: `${uploadProgress}%` }}
142
+ ></div>
143
+ </div>
144
+ <p className="text-xs text-text-secondary mt-1">Uploading: {uploadProgress}%</p>
145
+ </div>
146
+ )}
147
+
148
+ {error && (
149
+ <div className="mt-2 p-2 bg-error/10 border border-error/30 text-error rounded-md text-sm flex items-center">
150
+ <X className="h-4 w-4 mr-2 flex-shrink-0" />
151
+ <span>{error}</span>
152
+ </div>
153
+ )}
154
+ </div>
155
+ );
156
+ }
apps/frontend/src/app/[roomCode]/components/LoadingState.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import Modal from '@/components/ui/Modal';
5
+
6
+ interface LoadingStateProps {
7
+ isLoading: boolean;
8
+ error: string | null;
9
+ }
10
+
11
+ export default function LoadingState({ isLoading, error }: LoadingStateProps) {
12
+ if (!isLoading && !error) return null;
13
+
14
+ const loadingIcon = (
15
+ <div className="relative w-full h-full">
16
+ <div className="absolute inset-0 bg-gradient-mesh rounded-xl animate-pulse"></div>
17
+ <div className="absolute inset-0 flex items-center justify-center">
18
+ <svg className="animate-spin h-8 w-8 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
19
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
20
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
21
+ </svg>
22
+ </div>
23
+ </div>
24
+ );
25
+
26
+ const errorIcon = (
27
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
28
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
29
+ </svg>
30
+ );
31
+
32
+ return isLoading ? (
33
+ <Modal
34
+ isOpen={true}
35
+ onClose={() => {}}
36
+ title="Connecting to Clipboard"
37
+ icon={loadingIcon}
38
+ >
39
+ <div className="flex flex-col items-center">
40
+ <p className="text-text-secondary text-center">
41
+ Establishing connection and retrieving clipboard data...
42
+ </p>
43
+ </div>
44
+ </Modal>
45
+ ) : (
46
+ <Modal
47
+ isOpen={true}
48
+ onClose={() => {}}
49
+ title="Error"
50
+ icon={errorIcon}
51
+ >
52
+ <div className="flex flex-col items-center">
53
+ <p className="text-text-secondary text-center mb-6">
54
+ {error}
55
+ </p>
56
+ <Link href="/">
57
+ <button className="btn btn-primary">
58
+ Return to Home
59
+ </button>
60
+ </Link>
61
+ </div>
62
+ </Modal>
63
+ );
64
+ }
apps/frontend/src/app/[roomCode]/components/PasswordVerificationModal.tsx ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+
5
+ interface PasswordVerificationModalProps {
6
+ onVerify: (password: string) => Promise<boolean>;
7
+ onCancel: () => void;
8
+ isOpen: boolean;
9
+ }
10
+
11
+ // TODO: make the modal below use the reusable Modal component
12
+
13
+ export default function PasswordVerificationModal({
14
+ onVerify,
15
+ onCancel,
16
+ isOpen
17
+ }: PasswordVerificationModalProps) {
18
+ const [password, setPassword] = useState('');
19
+ const [error, setError] = useState('');
20
+ const [isVerifying, setIsVerifying] = useState(false);
21
+ const modalRef = useRef<HTMLDivElement>(null);
22
+ const inputRef = useRef<HTMLInputElement>(null);
23
+
24
+ // Focus input when modal opens
25
+ useEffect(() => {
26
+ if (isOpen && inputRef.current) {
27
+ setTimeout(() => {
28
+ inputRef.current?.focus();
29
+ }, 100);
30
+ }
31
+ }, [isOpen]);
32
+
33
+ // Handle click outside modal
34
+ useEffect(() => {
35
+ const handleClickOutside = (event: MouseEvent) => {
36
+ if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
37
+ onCancel();
38
+ }
39
+ };
40
+
41
+ if (isOpen) {
42
+ document.addEventListener('mousedown', handleClickOutside);
43
+ }
44
+
45
+ return () => {
46
+ document.removeEventListener('mousedown', handleClickOutside);
47
+ };
48
+ }, [isOpen, onCancel]);
49
+
50
+ // Handle ESC key to close modal
51
+ useEffect(() => {
52
+ const handleEscKey = (event: KeyboardEvent) => {
53
+ if (event.key === 'Escape') {
54
+ onCancel();
55
+ }
56
+ };
57
+
58
+ if (isOpen) {
59
+ window.addEventListener('keydown', handleEscKey);
60
+ }
61
+
62
+ return () => {
63
+ window.removeEventListener('keydown', handleEscKey);
64
+ };
65
+ }, [isOpen, onCancel]);
66
+
67
+ const handleSubmit = async (e: React.FormEvent) => {
68
+ e.preventDefault();
69
+ setError('');
70
+
71
+ if (!password.trim()) {
72
+ setError('Please enter a password');
73
+ return;
74
+ }
75
+
76
+ setIsVerifying(true);
77
+ try {
78
+ const success = await onVerify(password);
79
+ if (!success) {
80
+ setError('Incorrect password. Please try again.');
81
+ setPassword('');
82
+ inputRef.current?.focus();
83
+ }
84
+ } catch (err) {
85
+ setError('An error occurred while verifying the password. Please try again.');
86
+ } finally {
87
+ setIsVerifying(false);
88
+ }
89
+ };
90
+
91
+ if (!isOpen) return null;
92
+
93
+ return (
94
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fadeIn">
95
+ <div
96
+ ref={modalRef}
97
+ className="bg-surface border border-surface-hover rounded-xl p-6 w-full max-w-md mx-4 shadow-xl animate-scaleIn relative overflow-hidden"
98
+ >
99
+ {/* Background elements */}
100
+ <div className="absolute -top-20 -left-20 w-40 h-40 bg-emerald-500/5 rounded-full"></div>
101
+ <div className="absolute -bottom-20 -right-20 w-40 h-40 bg-emerald-500/5 rounded-full"></div>
102
+
103
+ <div className="relative">
104
+ <div className="flex items-center mb-4">
105
+ <div className="w-10 h-10 bg-emerald-500/10 rounded-full flex items-center justify-center mr-3">
106
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
107
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
108
+ </svg>
109
+ </div>
110
+ <h2 className="text-xl font-semibold text-text-primary">Password Protected</h2>
111
+ </div>
112
+
113
+ <p className="text-text-secondary mb-4">
114
+ This clipboard is password protected. Please enter the password to access it.
115
+ </p>
116
+
117
+ <form onSubmit={handleSubmit} className="space-y-4">
118
+ <div>
119
+ <div className="relative">
120
+ <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
121
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-text-secondary/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
122
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
123
+ </svg>
124
+ </div>
125
+ <input
126
+ ref={inputRef}
127
+ type="text"
128
+ value={password}
129
+ onChange={(e) => {
130
+ setPassword(e.target.value);
131
+ if (error) setError('');
132
+ }}
133
+ placeholder="Enter password"
134
+ className="w-full pl-10 pr-4 py-3 rounded-lg bg-surface/80 border-2 border-surface-hover focus:border-emerald-500 focus:outline-none focus:ring-0 transition-colors duration-300 ease-in-out text-text-primary placeholder-text-secondary/50 font-mono"
135
+ autoComplete="off"
136
+ />
137
+ </div>
138
+ {error && (
139
+ <div className="mt-2 text-error text-sm flex items-center">
140
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
141
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
142
+ </svg>
143
+ <span>{error}</span>
144
+ </div>
145
+ )}
146
+ </div>
147
+
148
+ <div className="flex space-x-3">
149
+ <button
150
+ type="button"
151
+ onClick={onCancel}
152
+ className="flex-1 py-2 px-4 border border-surface-hover bg-surface hover:bg-surface-hover text-text-primary font-medium rounded-lg transition-colors duration-200"
153
+ >
154
+ Cancel
155
+ </button>
156
+ <button
157
+ type="submit"
158
+ disabled={isVerifying}
159
+ className="flex-1 py-2 px-4 bg-emerald-500 hover:bg-emerald-600 text-white font-medium rounded-lg flex items-center justify-center transition-all duration-200 shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
160
+ >
161
+ {isVerifying ? (
162
+ <span className="flex items-center justify-center">
163
+ <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
164
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
165
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
166
+ </svg>
167
+ Verifying...
168
+ </span>
169
+ ) : 'Access Clipboard'}
170
+ </button>
171
+ </div>
172
+ </form>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ );
177
+ }
apps/frontend/src/app/[roomCode]/page.tsx ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+ import { useParams, useRouter } from 'next/navigation';
5
+ import { FileText, FileIcon } from 'lucide-react';
6
+
7
+ // Import components
8
+ import ClipboardHeader from './components/ClipboardHeader';
9
+ import ClipboardEntryForm from './components/ClipboardEntryForm';
10
+ import ClipboardEntryList from './components/ClipboardEntryList';
11
+ import LoadingState from './components/LoadingState';
12
+ import PasswordVerificationModal from './components/PasswordVerificationModal';
13
+ import { ClipboardEntryType } from './components/ClipboardEntry';
14
+ import FileUploadComponent from './components/FileUploadComponent';
15
+ import FileList from './components/FileList';
16
+ import { FileEntryType } from './components/FileEntry';
17
+
18
+ // Import custom hooks
19
+ import { useSocketManager } from '@/lib/hooks/useSocketManager';
20
+ import { useSavedClipboards } from '@/lib/hooks/useSavedClipboards';
21
+ import { apiUrl } from '@/lib/constants';
22
+
23
+ export default function ClipboardRoom() {
24
+ const params = useParams();
25
+ const router = useRouter();
26
+ const roomCode = params.roomCode as string;
27
+ const clientId = useRef(Math.random().toString(36).substring(2, 10));
28
+ const [copied, setCopied] = useState<string | null>(null);
29
+ const [isPasswordProtected, setIsPasswordProtected] = useState(false);
30
+ const [showPasswordModal, setShowPasswordModal] = useState(false);
31
+ const [isCheckingPassword, setIsCheckingPassword] = useState(true);
32
+ const [isPasswordVerified, setIsPasswordVerified] = useState(false);
33
+ const [activeTab, setActiveTab] = useState<'entries' | 'files'>('entries');
34
+
35
+ // Use saved clipboards hook to track clipboard history
36
+ const { addClipboard } = useSavedClipboards();
37
+
38
+ // Use our custom socket manager hook
39
+ const {
40
+ entries,
41
+ files,
42
+ connectedUsers,
43
+ expiresIn,
44
+ isLoading,
45
+ error,
46
+ socketRef,
47
+ addEntry: handleAddEntry,
48
+ deleteEntry: handleDeleteEntry,
49
+ clearClipboard: handleClearClipboard,
50
+ deleteFile: handleDeleteFile
51
+ } = useSocketManager({
52
+ roomCode,
53
+ clientId: clientId.current
54
+ });
55
+
56
+ /* ===== Event Handlers ===== */
57
+
58
+ const handleCopyEntry = (content: string, id: string) => {
59
+ navigator.clipboard.writeText(content);
60
+ setCopied(id);
61
+ setTimeout(() => setCopied(null), 2000);
62
+ };
63
+
64
+ const handleCopyLink = () => {
65
+ navigator.clipboard.writeText(window.location.href);
66
+ // You could add a toast notification here
67
+ };
68
+
69
+ // Handle file uploaded event
70
+ const handleFileUploaded = (fileEntry: FileEntryType) => {
71
+ // Emit socket event to notify other clients
72
+ if (socketRef) {
73
+ socketRef.current?.emit('fileUploaded', {
74
+ roomCode,
75
+ fileEntry,
76
+ clientId: clientId.current,
77
+ });
78
+ }
79
+
80
+ // Switch to files tab after upload to show the newly uploaded file
81
+ setActiveTab('files');
82
+ };
83
+
84
+ // Check for password protection when component mounts
85
+ useEffect(() => {
86
+ checkPasswordProtection();
87
+ // eslint-disable-next-line react-hooks/exhaustive-deps
88
+ }, [roomCode]);
89
+
90
+ // Update document title and favicon based on clipboard state
91
+ useEffect(() => {
92
+ if (typeof window !== 'undefined') {
93
+ // Update the document title with the number of entries
94
+ document.title = `Clipboard (${entries.length}) - ${roomCode}`;
95
+
96
+ // Update the favicon based on user count
97
+ const favicon = document.querySelector('link[rel="icon"]') as HTMLLinkElement;
98
+ if (favicon) {
99
+ favicon.href = connectedUsers > 1
100
+ ? '/favicon-active.ico'
101
+ : '/favicon.ico';
102
+ }
103
+ }
104
+ }, [entries.length, connectedUsers, roomCode]);
105
+
106
+ // Check if the clipboard is password protected
107
+ const checkPasswordProtection = async () => {
108
+ try {
109
+ setIsCheckingPassword(true);
110
+ const response = await fetch(`${apiUrl}/clipboard/${roomCode}/exists`);
111
+
112
+ if (!response.ok) {
113
+ throw new Error('Failed to check clipboard');
114
+ }
115
+
116
+ const data = await response.json();
117
+
118
+ if (!data.exists) {
119
+ // Clipboard doesn't exist
120
+ router.push('/');
121
+ return;
122
+ }
123
+
124
+ // Check if clipboard has a password
125
+ if (data.hasPassword) {
126
+ setIsPasswordProtected(true);
127
+ setShowPasswordModal(true);
128
+ } else {
129
+ // No password, proceed directly
130
+ setIsPasswordVerified(true);
131
+
132
+ // Save to clipboard history since we've successfully accessed it
133
+ addClipboard(roomCode);
134
+ }
135
+ } catch (err) {
136
+ console.error('Error checking clipboard:', err);
137
+ } finally {
138
+ setIsCheckingPassword(false);
139
+ }
140
+ };
141
+
142
+ // Verify clipboard password
143
+ const verifyPassword = async (password: string): Promise<boolean> => {
144
+ try {
145
+ const response = await fetch(`${apiUrl}/clipboard/${roomCode}/verify`, {
146
+ method: 'POST',
147
+ headers: {
148
+ 'Content-Type': 'application/json',
149
+ },
150
+ body: JSON.stringify({ password }),
151
+ });
152
+
153
+ if (response.ok) {
154
+ setIsPasswordVerified(true);
155
+ setShowPasswordModal(false);
156
+
157
+ // Save to clipboard history after successful password verification
158
+ addClipboard(roomCode);
159
+
160
+ return true;
161
+ }
162
+
163
+ return false;
164
+ } catch (err) {
165
+ console.error('Error verifying password:', err);
166
+ return false;
167
+ }
168
+ };
169
+
170
+ // Handle cancel password verification
171
+ const handleCancelPasswordVerification = () => {
172
+ router.push('/');
173
+ };
174
+
175
+ // Format date for display
176
+ const formatDate = (dateString: string) => {
177
+ const date = new Date(dateString);
178
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
179
+ };
180
+
181
+ return (
182
+ <div className="flex flex-col h-screen max-h-screen overflow-hidden bg-background text-text-primary">
183
+ {/* Loading and Error States */}
184
+ <LoadingState isLoading={isLoading || isCheckingPassword} error={error} />
185
+
186
+ {/* Password Verification Modal */}
187
+ <PasswordVerificationModal
188
+ onVerify={verifyPassword}
189
+ onCancel={handleCancelPasswordVerification}
190
+ isOpen={isPasswordProtected && showPasswordModal && !isPasswordVerified}
191
+ />
192
+
193
+ {/* Only show content if not password protected or password has been verified */}
194
+ {(!isPasswordProtected || isPasswordVerified) && (
195
+ <>
196
+ {/* Header with Room Code and User Count - Fixed at top */}
197
+ <ClipboardHeader
198
+ roomCode={roomCode}
199
+ connectedUsers={connectedUsers}
200
+ expiresIn={expiresIn}
201
+ onCopyLink={handleCopyLink}
202
+ />
203
+
204
+ {/* Scrollable content area - Only this part should scroll */}
205
+ <div className="flex-1 overflow-y-auto">
206
+ <main className="container mx-auto px-3 sm:px-4 py-4 sm:py-6 max-w-4xl">
207
+ {/* Unified Input Section */}
208
+ <div className="mb-6">
209
+ {activeTab === 'entries' ? (
210
+ <ClipboardEntryForm onAddEntry={handleAddEntry} />
211
+ ) : (
212
+ <FileUploadComponent
213
+ roomCode={roomCode}
214
+ clientId={clientId.current}
215
+ onFileUploaded={handleFileUploaded}
216
+ />
217
+ )}
218
+ </div>
219
+
220
+ {/* Tabs Navigation */}
221
+ <div className="flex border-b border-surface-hover mb-4">
222
+ <button
223
+ onClick={() => setActiveTab('entries')}
224
+ className={`flex items-center px-4 py-2 border-b-2 font-medium text-sm ${activeTab === 'entries'
225
+ ? 'border-primary text-primary'
226
+ : 'border-transparent text-text-secondary hover:text-text-primary hover:border-surface-hover'
227
+ } transition-colors`}
228
+ >
229
+ <FileText className="w-4 h-4 mr-2" />
230
+ Text Entries
231
+ {entries.length > 0 && (
232
+ <span className="ml-2 bg-surface-hover text-text-secondary text-xs px-2 py-0.5 rounded-full">
233
+ {entries.length}
234
+ </span>
235
+ )}
236
+ </button>
237
+
238
+ <button
239
+ onClick={() => setActiveTab('files')}
240
+ className={`flex items-center px-4 py-2 border-b-2 font-medium text-sm ${activeTab === 'files'
241
+ ? 'border-primary text-primary'
242
+ : 'border-transparent text-text-secondary hover:text-text-primary hover:border-surface-hover'
243
+ } transition-colors`}
244
+ >
245
+ <FileIcon className="w-4 h-4 mr-2" />
246
+ Files
247
+ {files.length > 0 && (
248
+ <span className="ml-2 bg-surface-hover text-text-secondary text-xs px-2 py-0.5 rounded-full">
249
+ {files.length}
250
+ </span>
251
+ )}
252
+ </button>
253
+ </div>
254
+
255
+ {/* Tab Content */}
256
+ <div className="mt-4">
257
+ {activeTab === 'entries' ? (
258
+ <ClipboardEntryList
259
+ entries={entries}
260
+ copiedId={copied}
261
+ onCopyEntry={handleCopyEntry}
262
+ onDeleteEntry={handleDeleteEntry}
263
+ onClearAll={handleClearClipboard}
264
+ />
265
+ ) : (
266
+ <FileList
267
+ files={files}
268
+ onDeleteFile={handleDeleteFile}
269
+ roomCode={roomCode}
270
+ />
271
+ )}
272
+ </div>
273
+ </main>
274
+ </div>
275
+ </>
276
+ )}
277
+ </div>
278
+ );
279
+ }
apps/frontend/src/app/admin/[roomCode]/page.tsx ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useState } from 'react';
4
+ import Link from 'next/link';
5
+ import { useParams, useRouter } from 'next/navigation';
6
+ import { apiUrl } from '@/lib/constants';
7
+
8
+ interface ClipboardEntry {
9
+ id: string;
10
+ content: string;
11
+ createdAt: string;
12
+ createdBy: string;
13
+ }
14
+
15
+ interface FileEntry {
16
+ id: string;
17
+ filename: string;
18
+ mimetype: string;
19
+ size: number;
20
+ storageKey: string;
21
+ createdAt: string;
22
+ createdBy: string;
23
+ }
24
+
25
+ interface ClipboardDetail {
26
+ id: string;
27
+ entries: ClipboardEntry[];
28
+ files?: FileEntry[];
29
+ password?: string;
30
+ createdAt: string;
31
+ lastActivity: string;
32
+ ttl: number | null;
33
+ }
34
+
35
+ export default function ClipboardAdminDetail() {
36
+ const params = useParams();
37
+ const router = useRouter();
38
+ const roomCode = params.roomCode as string;
39
+
40
+ const [token, setToken] = useState('');
41
+ const [inputToken, setInputToken] = useState('');
42
+ const [clipboard, setClipboard] = useState<ClipboardDetail | null>(null);
43
+ const [loading, setLoading] = useState(false);
44
+ const [error, setError] = useState('');
45
+ const [password, setPassword] = useState('');
46
+ const [ttl, setTTL] = useState('');
47
+
48
+ const tokenValid = useMemo(() => Boolean(token), [token]);
49
+
50
+ useEffect(() => {
51
+ const saved = localStorage.getItem('adminToken');
52
+ if (saved) {
53
+ setToken(saved);
54
+ setInputToken(saved);
55
+ }
56
+ }, []);
57
+
58
+ useEffect(() => {
59
+ if (token) {
60
+ fetchClipboard();
61
+ }
62
+ // eslint-disable-next-line react-hooks/exhaustive-deps
63
+ }, [token]);
64
+
65
+ const fetchClipboard = async () => {
66
+ setLoading(true);
67
+ setError('');
68
+
69
+ try {
70
+ const response = await fetch(`${apiUrl}/admin/clipboards/${roomCode}`, {
71
+ headers: { 'x-admin-token': token },
72
+ });
73
+
74
+ if (response.status === 401) {
75
+ setError('Invalid token. Please log in again.');
76
+ return;
77
+ }
78
+
79
+ if (!response.ok) {
80
+ throw new Error('Failed to load clipboard');
81
+ }
82
+
83
+ const data = await response.json();
84
+ setClipboard(data);
85
+ setPassword(data.password || '');
86
+ setTTL(data.ttl ? String(data.ttl) : '');
87
+ } catch (err) {
88
+ setError(err instanceof Error ? err.message : 'Unknown error');
89
+ } finally {
90
+ setLoading(false);
91
+ }
92
+ };
93
+
94
+ const handleLogin = () => {
95
+ if (!inputToken) {
96
+ setError('Please enter an admin token');
97
+ return;
98
+ }
99
+
100
+ localStorage.setItem('adminToken', inputToken);
101
+ setToken(inputToken);
102
+ setError('');
103
+ };
104
+
105
+ const updatePassword = async (value: string | null) => {
106
+ try {
107
+ const response = await fetch(`${apiUrl}/admin/clipboards/${roomCode}/password`, {
108
+ method: 'POST',
109
+ headers: {
110
+ 'Content-Type': 'application/json',
111
+ 'x-admin-token': token,
112
+ },
113
+ body: JSON.stringify({ password: value }),
114
+ });
115
+
116
+ if (!response.ok) {
117
+ throw new Error('Failed to update password');
118
+ }
119
+
120
+ await fetchClipboard();
121
+ } catch (err) {
122
+ setError(err instanceof Error ? err.message : 'Unknown error');
123
+ }
124
+ };
125
+
126
+ const updateTTL = async (value: number | null) => {
127
+ try {
128
+ const response = await fetch(`${apiUrl}/admin/clipboards/${roomCode}/ttl`, {
129
+ method: 'POST',
130
+ headers: {
131
+ 'Content-Type': 'application/json',
132
+ 'x-admin-token': token,
133
+ },
134
+ body: JSON.stringify({ ttl: value }),
135
+ });
136
+
137
+ if (!response.ok) {
138
+ throw new Error('Failed to update TTL');
139
+ }
140
+
141
+ await fetchClipboard();
142
+ } catch (err) {
143
+ setError(err instanceof Error ? err.message : 'Unknown error');
144
+ }
145
+ };
146
+
147
+ const handleDelete = async () => {
148
+ if (!confirm(`Delete clipboard ${roomCode}? This cannot be undone.`)) {
149
+ return;
150
+ }
151
+
152
+ try {
153
+ const response = await fetch(`${apiUrl}/admin/clipboards/${roomCode}`, {
154
+ method: 'DELETE',
155
+ headers: { 'x-admin-token': token },
156
+ });
157
+
158
+ if (!response.ok) {
159
+ throw new Error('Failed to delete clipboard');
160
+ }
161
+
162
+ router.push('/admin');
163
+ } catch (err) {
164
+ setError(err instanceof Error ? err.message : 'Unknown error');
165
+ }
166
+ };
167
+
168
+ return (
169
+ <div className="w-full max-w-6xl mx-auto p-6 md:p-10">
170
+ <div className="flex items-center justify-between mb-6">
171
+ <div>
172
+ <p className="text-sm text-text-secondary">Clipboard</p>
173
+ <h1 className="text-3xl font-bold">{roomCode}</h1>
174
+ </div>
175
+ <div className="flex gap-2">
176
+ <Link href="/admin" className="btn btn-ghost">
177
+ Back to List
178
+ </Link>
179
+ <Link href={`/${roomCode}`} className="btn btn-ghost">
180
+ Open Clipboard
181
+ </Link>
182
+ </div>
183
+ </div>
184
+
185
+ <div className="card mb-6">
186
+ <div className="flex flex-col sm:flex-row sm:items-end gap-4">
187
+ <div className="flex-1">
188
+ <label className="block text-sm text-text-secondary mb-2">Admin Token</label>
189
+ <input
190
+ type="password"
191
+ value={inputToken}
192
+ onChange={(e) => setInputToken(e.target.value)}
193
+ className="input w-full"
194
+ placeholder="Enter admin token"
195
+ />
196
+ </div>
197
+ <button className="btn btn-primary self-start sm:self-auto" onClick={handleLogin}>
198
+ {tokenValid ? 'Update Token' : 'Login'}
199
+ </button>
200
+ </div>
201
+ {error && <p className="text-red-400 text-sm mt-2">{error}</p>}
202
+ </div>
203
+
204
+ {tokenValid && (
205
+ <div className="space-y-6">
206
+ <div className="card flex flex-col gap-4">
207
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
208
+ <div>
209
+ <p className="text-text-secondary text-sm">Created {clipboard ? new Date(clipboard.createdAt).toLocaleString() : ''}</p>
210
+ <p className="text-text-secondary text-sm">Last activity {clipboard ? new Date(clipboard.lastActivity).toLocaleString() : ''}</p>
211
+ </div>
212
+ <div className="flex gap-2">
213
+ <button className="btn btn-ghost" onClick={fetchClipboard} disabled={loading}>
214
+ Refresh
215
+ </button>
216
+ <button className="btn btn-ghost" onClick={handleDelete}>
217
+ Delete
218
+ </button>
219
+ </div>
220
+ </div>
221
+
222
+ <div className="grid md:grid-cols-2 gap-4">
223
+ <div>
224
+ <label className="block text-sm text-text-secondary mb-2">Password</label>
225
+ <div className="flex gap-2">
226
+ <input
227
+ type="text"
228
+ value={password}
229
+ onChange={(e) => setPassword(e.target.value)}
230
+ className="input flex-1"
231
+ placeholder="Set or clear password"
232
+ />
233
+ <button className="btn btn-secondary" onClick={() => updatePassword(password || null)}>
234
+ Save
235
+ </button>
236
+ <button className="btn btn-ghost" onClick={() => { setPassword(''); updatePassword(null); }}>
237
+ Clear
238
+ </button>
239
+ </div>
240
+ </div>
241
+ <div>
242
+ <label className="block text-sm text-text-secondary mb-2">TTL (seconds)</label>
243
+ <div className="flex gap-2">
244
+ <input
245
+ type="number"
246
+ min="0"
247
+ value={ttl}
248
+ onChange={(e) => setTTL(e.target.value)}
249
+ className="input flex-1"
250
+ placeholder="e.g. 3600"
251
+ />
252
+ <button
253
+ className="btn btn-secondary"
254
+ onClick={() => updateTTL(ttl ? Number(ttl) : null)}
255
+ >
256
+ Save
257
+ </button>
258
+ <button className="btn btn-ghost" onClick={() => { setTTL(''); updateTTL(null); }}>
259
+ Clear
260
+ </button>
261
+ </div>
262
+ <p className="text-sm text-text-secondary mt-1">Current TTL: {clipboard ? formatTTL(clipboard.ttl) : '—'}</p>
263
+ </div>
264
+ </div>
265
+ </div>
266
+
267
+ <div className="card">
268
+ <div className="flex items-center justify-between mb-3">
269
+ <h2 className="text-xl font-semibold">Entries ({clipboard?.entries.length ?? 0})</h2>
270
+ </div>
271
+ {loading && !clipboard ? (
272
+ <p className="text-text-secondary">Loading...</p>
273
+ ) : (
274
+ <div className="space-y-3">
275
+ {clipboard?.entries.map((entry) => (
276
+ <div key={entry.id} className="clipboard-entry">
277
+ <div className="flex items-center justify-between mb-2 text-xs text-text-secondary">
278
+ <span>{new Date(entry.createdAt).toLocaleString()}</span>
279
+ <span className="font-mono">{entry.createdBy}</span>
280
+ </div>
281
+ <div className="clipboard-entry-content">{entry.content}</div>
282
+ </div>
283
+ ))}
284
+ {clipboard && clipboard.entries.length === 0 && (
285
+ <p className="text-text-secondary">No entries yet.</p>
286
+ )}
287
+ </div>
288
+ )}
289
+ </div>
290
+
291
+ <div className="card">
292
+ <div className="flex items-center justify-between mb-3">
293
+ <h2 className="text-xl font-semibold">Files ({clipboard?.files?.length ?? 0})</h2>
294
+ </div>
295
+ {clipboard?.files && clipboard.files.length > 0 ? (
296
+ <div className="space-y-3">
297
+ {clipboard.files.map((file) => (
298
+ <div key={file.id} className="clipboard-entry">
299
+ <div className="flex items-center justify-between">
300
+ <div>
301
+ <p className="font-medium">{file.filename}</p>
302
+ <p className="text-xs text-text-secondary">{file.mimetype} • {formatSize(file.size)}</p>
303
+ <p className="text-xs text-text-secondary">Uploaded {new Date(file.createdAt).toLocaleString()}</p>
304
+ </div>
305
+ <a
306
+ href={`${apiUrl}/files/${file.storageKey}`}
307
+ className="btn btn-ghost btn-sm"
308
+ target="_blank"
309
+ rel="noopener noreferrer"
310
+ >
311
+ Open
312
+ </a>
313
+ </div>
314
+ </div>
315
+ ))}
316
+ </div>
317
+ ) : (
318
+ <p className="text-text-secondary">No files uploaded.</p>
319
+ )}
320
+ </div>
321
+ </div>
322
+ )}
323
+ </div>
324
+ );
325
+ }
326
+
327
+ function formatTTL(ttl: number | null): string {
328
+ if (ttl === null) return 'None';
329
+ if (ttl < 60) return `${ttl}s`;
330
+ if (ttl < 3600) return `${Math.round(ttl / 60)}m`;
331
+ const hours = Math.round(ttl / 3600);
332
+ return `${hours}h`;
333
+ }
334
+
335
+ function formatSize(bytes: number) {
336
+ if (bytes < 1024) return `${bytes} B`;
337
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
338
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
339
+ }
apps/frontend/src/app/admin/page.tsx ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useState } from 'react';
4
+ import Link from 'next/link';
5
+ import { apiUrl } from '@/lib/constants';
6
+
7
+ interface ClipboardSummary {
8
+ id: string;
9
+ createdAt: string;
10
+ lastActivity: string;
11
+ hasPassword: boolean;
12
+ entryCount: number;
13
+ fileCount: number;
14
+ ttl: number | null;
15
+ }
16
+
17
+ export default function AdminDashboard() {
18
+ const [token, setToken] = useState('');
19
+ const [inputToken, setInputToken] = useState('');
20
+ const [clipboards, setClipboards] = useState<ClipboardSummary[]>([]);
21
+ const [loading, setLoading] = useState(false);
22
+ const [error, setError] = useState('');
23
+
24
+ useEffect(() => {
25
+ const saved = localStorage.getItem('adminToken');
26
+ if (saved) {
27
+ setToken(saved);
28
+ setInputToken(saved);
29
+ }
30
+ }, []);
31
+
32
+ useEffect(() => {
33
+ if (token) {
34
+ fetchClipboards();
35
+ }
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ }, [token]);
38
+
39
+ const fetchClipboards = async () => {
40
+ setLoading(true);
41
+ setError('');
42
+
43
+ try {
44
+ const response = await fetch(`${apiUrl}/admin/clipboards`, {
45
+ headers: { 'x-admin-token': token },
46
+ });
47
+
48
+ if (response.status === 401) {
49
+ setError('Invalid token. Please log in again.');
50
+ return;
51
+ }
52
+
53
+ if (!response.ok) {
54
+ throw new Error('Failed to fetch clipboards');
55
+ }
56
+
57
+ const data = await response.json();
58
+ setClipboards(data);
59
+ } catch (err) {
60
+ setError(err instanceof Error ? err.message : 'Unknown error');
61
+ } finally {
62
+ setLoading(false);
63
+ }
64
+ };
65
+
66
+ const handleLogin = () => {
67
+ if (!inputToken) {
68
+ setError('Please enter an admin token');
69
+ return;
70
+ }
71
+
72
+ localStorage.setItem('adminToken', inputToken);
73
+ setToken(inputToken);
74
+ setError('');
75
+ };
76
+
77
+ const handleDelete = async (roomCode: string) => {
78
+ if (!confirm(`Delete clipboard ${roomCode}? This cannot be undone.`)) {
79
+ return;
80
+ }
81
+
82
+ try {
83
+ const response = await fetch(`${apiUrl}/admin/clipboards/${roomCode}`, {
84
+ method: 'DELETE',
85
+ headers: { 'x-admin-token': token },
86
+ });
87
+
88
+ if (!response.ok) {
89
+ throw new Error('Failed to delete clipboard');
90
+ }
91
+
92
+ await fetchClipboards();
93
+ } catch (err) {
94
+ setError(err instanceof Error ? err.message : 'Unknown error');
95
+ }
96
+ };
97
+
98
+ const tokenValid = useMemo(() => Boolean(token), [token]);
99
+
100
+ return (
101
+ <div className="w-full max-w-6xl mx-auto p-6 md:p-10">
102
+ <div className="flex items-center justify-between mb-6">
103
+ <div>
104
+ <h1 className="text-3xl font-bold">Admin Dashboard</h1>
105
+ <p className="text-text-secondary">Manage clipboards and inspect activity</p>
106
+ </div>
107
+ <Link href="/" className="btn btn-ghost">
108
+ Back to Home
109
+ </Link>
110
+ </div>
111
+
112
+ <div className="card mb-6">
113
+ <div className="flex flex-col sm:flex-row sm:items-end gap-4">
114
+ <div className="flex-1">
115
+ <label className="block text-sm text-text-secondary mb-2">Admin Token</label>
116
+ <input
117
+ type="password"
118
+ value={inputToken}
119
+ onChange={(e) => setInputToken(e.target.value)}
120
+ className="input w-full"
121
+ placeholder="Enter admin token"
122
+ />
123
+ </div>
124
+ <button className="btn btn-primary self-start sm:self-auto" onClick={handleLogin}>
125
+ {tokenValid ? 'Update Token' : 'Login'}
126
+ </button>
127
+ </div>
128
+ {error && <p className="text-red-400 text-sm mt-2">{error}</p>}
129
+ </div>
130
+
131
+ {tokenValid && (
132
+ <div className="card">
133
+ <div className="flex items-center justify-between mb-4">
134
+ <h2 className="text-xl font-semibold">Clipboards</h2>
135
+ <div className="flex gap-2">
136
+ <button className="btn btn-ghost" onClick={fetchClipboards} disabled={loading}>
137
+ Refresh
138
+ </button>
139
+ </div>
140
+ </div>
141
+ {loading ? (
142
+ <p className="text-text-secondary">Loading clipboards...</p>
143
+ ) : (
144
+ <div className="overflow-x-auto">
145
+ <table className="min-w-full text-sm">
146
+ <thead className="text-left text-text-secondary border-b border-surface-hover">
147
+ <tr>
148
+ <th className="py-2 pr-4">ID</th>
149
+ <th className="py-2 pr-4">Entries</th>
150
+ <th className="py-2 pr-4">Files</th>
151
+ <th className="py-2 pr-4">Password</th>
152
+ <th className="py-2 pr-4">TTL</th>
153
+ <th className="py-2 pr-4">Last Activity</th>
154
+ <th className="py-2 pr-4 text-right">Actions</th>
155
+ </tr>
156
+ </thead>
157
+ <tbody>
158
+ {clipboards.length === 0 && (
159
+ <tr>
160
+ <td className="py-4 text-text-secondary" colSpan={7}>
161
+ No clipboards found.
162
+ </td>
163
+ </tr>
164
+ )}
165
+ {clipboards.map((clipboard) => (
166
+ <tr key={clipboard.id} className="border-b border-surface-hover">
167
+ <td className="py-3 pr-4 font-mono">{clipboard.id}</td>
168
+ <td className="py-3 pr-4">{clipboard.entryCount}</td>
169
+ <td className="py-3 pr-4">{clipboard.fileCount}</td>
170
+ <td className="py-3 pr-4">{clipboard.hasPassword ? 'Yes' : 'No'}</td>
171
+ <td className="py-3 pr-4">{formatTTL(clipboard.ttl)}</td>
172
+ <td className="py-3 pr-4 text-text-secondary">{new Date(clipboard.lastActivity).toLocaleString()}</td>
173
+ <td className="py-3 pr-4 text-right">
174
+ <div className="flex justify-end gap-2">
175
+ <Link href={`/admin/${clipboard.id}`} className="btn btn-secondary btn-sm">
176
+ View
177
+ </Link>
178
+ <button className="btn btn-ghost btn-sm" onClick={() => handleDelete(clipboard.id)}>
179
+ Delete
180
+ </button>
181
+ </div>
182
+ </td>
183
+ </tr>
184
+ ))}
185
+ </tbody>
186
+ </table>
187
+ </div>
188
+ )}
189
+ </div>
190
+ )}
191
+ </div>
192
+ );
193
+ }
194
+
195
+ function formatTTL(ttl: number | null): string {
196
+ if (ttl === null) return 'None';
197
+ if (ttl < 60) return `${ttl}s`;
198
+ if (ttl < 3600) return `${Math.round(ttl / 60)}m`;
199
+ const hours = Math.round(ttl / 3600);
200
+ return `${hours}h`;
201
+ }
apps/frontend/src/app/favicon.ico ADDED
apps/frontend/src/app/layout.tsx ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import '@/styles/globals.css';
2
+ import type { Metadata } from 'next';
3
+ import { ToastProvider } from '@/components/ui/Toast';
4
+
5
+ export const metadata: Metadata = {
6
+ title: 'Clipboard Online',
7
+ description: 'Real-time clipboard sharing for text and files across all your devices',
8
+ keywords: [
9
+ 'clipboard',
10
+ 'online',
11
+ 'sharing',
12
+ 'real-time',
13
+ 'sync',
14
+ 'files',
15
+ 'text',
16
+ 'collaboration',
17
+ 'copy paste online',
18
+ 'universal clipboard',
19
+ 'cloud clipboard',
20
+ 'shared clipboard',
21
+ 'remote clipboard',
22
+ 'device sync',
23
+ 'cross-device copy',
24
+ 'file transfer tool',
25
+ 'text synchronization',
26
+ 'productivity tool',
27
+ 'multi-device clipboard',
28
+ 'web clipboard',
29
+ 'secure clipboard sharing',
30
+ 'instant clipboard',
31
+ 'digital clipboard',
32
+ 'data transfer',
33
+ 'seamless sharing',
34
+ 'clipboard manager',
35
+ 'online copy paste',
36
+ 'pasteboard online',
37
+ ],
38
+ };
39
+
40
+ export default function RootLayout({
41
+ children,
42
+ }: {
43
+ children: React.ReactNode;
44
+ }) {
45
+ return (
46
+ <html lang="en">
47
+ <head>
48
+ <link rel="icon" href="/favicon.ico" />
49
+ <meta name="theme-color" content="#121212" />
50
+ </head>
51
+ <body>
52
+ <ToastProvider>
53
+ <div className="relative">
54
+ {/* Decorative elements */}
55
+ <div className="fixed inset-0 z-[-1] overflow-hidden">
56
+ <div className="absolute -top-[10%] -left-[10%] w-[40%] h-[40%] bg-gradient-radial from-primary/5 to-transparent opacity-30 blur-3xl" />
57
+ <div className="absolute -bottom-[10%] -right-[10%] w-[40%] h-[40%] bg-gradient-radial from-secondary/5 to-transparent opacity-30 blur-3xl" />
58
+ </div>
59
+
60
+ <main className="flex flex-col min-h-screen">
61
+ {children}
62
+ </main>
63
+ </div>
64
+ </ToastProvider>
65
+ </body>
66
+ </html>
67
+ );
68
+ }
apps/frontend/src/app/page.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import CreateClipboardCard from '@/components/CreateClipboardCard';
5
+ import JoinClipboardCard from '@/components/JoinClipboardCard';
6
+ import FeaturesSection from '@/components/FeaturesSection';
7
+ import Logo from '@/components/Logo';
8
+
9
+ export default function Home() {
10
+ return (
11
+ <div className="flex flex-col flex-1 relative">
12
+
13
+ {/* GitHub Link */}
14
+ <div className="absolute top-4 sm:right-4 left-1/2 sm:left-auto transform -translate-x-1/2 sm:-translate-x-0 flex space-x-3 items-center z-10">
15
+ <a
16
+ href="https://github.com/bilodev7/myclipboard.online"
17
+ target="_blank"
18
+ rel="noopener noreferrer"
19
+ className="inline-flex items-center px-3 py-1.5 rounded-lg border border-surface-hover bg-surface/50 backdrop-blur-sm hover:bg-surface-hover transition-colors duration-200 text-text-primary shadow-md hover:shadow-glow-sm group"
20
+ >
21
+ <svg className="h-5 w-5 mr-2 text-primary group-hover:text-primary/90" viewBox="0 0 16 16" fill="currentColor">
22
+ <path fillRule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
23
+ </svg>
24
+ <span className="font-medium">GitHub</span>
25
+ {/* <div className="ml-2 px-2 py-0.5 bg-surface/80 rounded-md text-xs flex items-center">
26
+ <svg className="h-3 w-3 mr-1 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
27
+ <path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"></path>
28
+ </svg>
29
+ <span>1.2k</span>
30
+ </div> */}
31
+ </a>
32
+ </div>
33
+
34
+ <div className="w-full max-w-5xl mx-auto flex-1 flex flex-col p-6 sm:p-8 sm:py-14">
35
+ {/* Main Content Container - This keeps content centered vertically */}
36
+ <div className="flex-1 flex flex-col justify-center mt-20 sm:mt-0">
37
+ {/* Logo and Title */}
38
+ <div className="mb-10 sm:mb-12 flex flex-col items-center">
39
+ <div className="flex items-center gap-3">
40
+ {/* <div className='overflow-hidden rounded-lg'>
41
+ <Logo />
42
+ </div> */}
43
+ <h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-text-primary bg-clip-text text-transparent bg-gradient-to-r from-primary to-secondary">
44
+ Clipboard Online
45
+ </h1>
46
+ </div>
47
+ <p className="text-lg text-center text-text-secondary mt-2">
48
+ Real-time clipboard sharing for text and files
49
+ </p>
50
+ </div>
51
+
52
+ {/* Cards Container - Side by side on desktop, stacked on mobile */}
53
+ <div className="flex flex-col md:flex-row gap-6 md:gap-8 mb-10">
54
+ <JoinClipboardCard />
55
+ <CreateClipboardCard />
56
+ </div>
57
+
58
+ {/* Features */}
59
+ <FeaturesSection />
60
+
61
+ </div>
62
+ </div>
63
+
64
+ {/* Footer */}
65
+ <div className="text-center text-text-secondary text-xs sm:text-sm py-4 mt-auto border-t border-surface-hover">
66
+ <p className="flex flex-col sm:flex-row justify-center items-center space-y-2 sm:space-y-0 sm:space-x-2">
67
+ <span>{new Date().getFullYear()} Clipboard Online</span>
68
+ <span>A real-time clipboard sharing tool - Developed by Bilo</span>
69
+ </p>
70
+ </div>
71
+ </div>
72
+ );
73
+ }
apps/frontend/src/assets/logo.png ADDED

Git LFS Details

  • SHA256: 84f5631077805390494824892423c65a902f97f3853ac67e91a4573440803347
  • Pointer size: 132 Bytes
  • Size of remote file: 1.1 MB
apps/frontend/src/components/CreateClipboardCard.tsx ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import PasswordModal from './PasswordModal';
6
+ import { useToast } from './ui/Toast';
7
+ import { apiUrl } from '@/lib/constants';
8
+
9
+ export default function CreateClipboardCard() {
10
+ const [isLoading, setIsLoading] = useState(false);
11
+ const [showPasswordModal, setShowPasswordModal] = useState(false);
12
+ const router = useRouter();
13
+ const { addToast } = useToast();
14
+
15
+ const handleCreateClipboard = async (withPassword = false) => {
16
+ if (withPassword) {
17
+ setShowPasswordModal(true);
18
+ return;
19
+ }
20
+
21
+ await createClipboard();
22
+ };
23
+
24
+ const createClipboard = async (passwordToUse?: string) => {
25
+ setIsLoading(true);
26
+ try {
27
+ const response = await fetch(`${apiUrl}/clipboard/create`, {
28
+ method: 'POST',
29
+ headers: {
30
+ 'Content-Type': 'application/json',
31
+ },
32
+ body: passwordToUse ? JSON.stringify({ password: passwordToUse }) : undefined,
33
+ });
34
+
35
+ if (!response.ok) {
36
+ const errorData = await response.json();
37
+ throw new Error(errorData.message || 'Failed to create clipboard');
38
+ }
39
+
40
+ const data = await response.json();
41
+ if (data.roomCode) {
42
+ router.push(`/${data.roomCode}`);
43
+ } else {
44
+ throw new Error('Room code not received from server');
45
+ }
46
+ } catch (err: any) {
47
+ console.error('Error creating clipboard:', err);
48
+ addToast(err.message || 'An unexpected error occurred', 'error', 'Error');
49
+ } finally {
50
+ setIsLoading(false);
51
+ setShowPasswordModal(false);
52
+ }
53
+ };
54
+
55
+ const handlePasswordSubmit = (password: string) => {
56
+ createClipboard(password);
57
+ };
58
+
59
+ return (
60
+ <>
61
+
62
+ <div className="flex-1 p-6 md:p-8 rounded-xl border border-surface-hover bg-surface/50 backdrop-blur-sm shadow-lg hover:shadow-glow-sm transition-all duration-300 ease-out relative overflow-hidden group">
63
+ {/* Background elements */}
64
+ <div className="absolute -top-10 -left-10 w-32 h-32 bg-primary/10 rounded-full animate-pulse-slow group-hover:scale-110 transition-transform duration-500"></div>
65
+ <div className="absolute -bottom-16 -right-16 w-40 h-40 bg-primary/5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
66
+
67
+ <div className="relative z-10 flex flex-col h-full gap-10 md:gap-0">
68
+ <div className="flex items-center mb-auto">
69
+ <div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center mr-3">
70
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
71
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
72
+ </svg>
73
+ </div>
74
+ <h2 className="text-2xl font-semibold text-text-primary">New Clipboard</h2>
75
+ </div>
76
+
77
+ <p className="text-text-secondary my-auto pl-1">
78
+ Start a new shared clipboard. A unique 4-character code will be generated for you.
79
+ </p>
80
+
81
+ <div className="flex mt-auto w-full">
82
+ <button
83
+ onClick={() => handleCreateClipboard(false)}
84
+ disabled={isLoading}
85
+ className="flex-grow py-3 px-4 bg-primary hover:bg-primary/90 text-white font-medium rounded-l-lg flex items-center justify-center transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 disabled:cursor-not-allowed"
86
+ >
87
+ {isLoading ? (
88
+ <span className="flex items-center justify-center">
89
+ <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
90
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
91
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
92
+ </svg>
93
+ Creating...
94
+ </span>
95
+ ) : (
96
+ <>
97
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
98
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
99
+ </svg>
100
+ Create Clipboard
101
+ </>
102
+ )}
103
+ </button>
104
+ <button
105
+ onClick={() => handleCreateClipboard(true)}
106
+ disabled={isLoading}
107
+ className="w-12 py-3 px-0 bg-primary hover:bg-primary/90 text-white font-medium rounded-r-lg flex items-center justify-center transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 disabled:cursor-not-allowed border-l border-primary-dark"
108
+ aria-label="Create password-protected clipboard"
109
+ title="Create with password"
110
+ >
111
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
112
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
113
+ </svg>
114
+ </button>
115
+ </div>
116
+ </div>
117
+
118
+ {/* Error is now displayed as a toast notification */}
119
+
120
+ </div>
121
+
122
+ {/* Password Modal */}
123
+ <PasswordModal
124
+ isOpen={showPasswordModal}
125
+ onClose={() => setShowPasswordModal(false)}
126
+ onSubmit={handlePasswordSubmit}
127
+ isLoading={isLoading}
128
+ />
129
+ </>
130
+ );
131
+ }