first commit
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +7 -0
- .gitignore +66 -0
- Dockerfile +40 -0
- README.md +75 -10
- apps/backend/package.json +47 -0
- apps/backend/pnpm-lock.yaml +0 -0
- apps/backend/src/admin/admin.controller.ts +41 -0
- apps/backend/src/admin/admin.guard.ts +23 -0
- apps/backend/src/admin/admin.module.ts +12 -0
- apps/backend/src/admin/admin.service.ts +54 -0
- apps/backend/src/app.module.ts +19 -0
- apps/backend/src/clipboard/clipboard.controller.ts +57 -0
- apps/backend/src/clipboard/clipboard.gateway.ts +287 -0
- apps/backend/src/clipboard/clipboard.module.ts +13 -0
- apps/backend/src/clipboard/clipboard.service.ts +386 -0
- apps/backend/src/common/filesystem/filesystem.controller.ts +43 -0
- apps/backend/src/common/filesystem/filesystem.module.ts +10 -0
- apps/backend/src/common/filesystem/filesystem.service.ts +82 -0
- apps/backend/src/common/filesystem/init-uploads.js +13 -0
- apps/backend/src/common/redis/redis.module.ts +9 -0
- apps/backend/src/common/redis/redis.service.ts +125 -0
- apps/backend/src/file/file.controller.ts +93 -0
- apps/backend/src/file/file.module.ts +12 -0
- apps/backend/src/file/file.service.ts +118 -0
- apps/backend/src/main.ts +50 -0
- apps/backend/tsconfig.json +24 -0
- apps/backend/types.d.ts +0 -0
- apps/frontend/next.config.mjs +19 -0
- apps/frontend/package.json +34 -0
- apps/frontend/pnpm-lock.yaml +0 -0
- apps/frontend/postcss.config.js +6 -0
- apps/frontend/public/logo.png +3 -0
- apps/frontend/src/app/[roomCode]/components/ClipboardEntry.tsx +66 -0
- apps/frontend/src/app/[roomCode]/components/ClipboardEntryForm.tsx +107 -0
- apps/frontend/src/app/[roomCode]/components/ClipboardEntryList.tsx +78 -0
- apps/frontend/src/app/[roomCode]/components/ClipboardHeader.tsx +97 -0
- apps/frontend/src/app/[roomCode]/components/FileEntry.ts +9 -0
- apps/frontend/src/app/[roomCode]/components/FileList.tsx +172 -0
- apps/frontend/src/app/[roomCode]/components/FilePreview.tsx +186 -0
- apps/frontend/src/app/[roomCode]/components/FileUploadComponent.tsx +156 -0
- apps/frontend/src/app/[roomCode]/components/LoadingState.tsx +64 -0
- apps/frontend/src/app/[roomCode]/components/PasswordVerificationModal.tsx +177 -0
- apps/frontend/src/app/[roomCode]/page.tsx +279 -0
- apps/frontend/src/app/admin/[roomCode]/page.tsx +339 -0
- apps/frontend/src/app/admin/page.tsx +201 -0
- apps/frontend/src/app/favicon.ico +0 -0
- apps/frontend/src/app/layout.tsx +68 -0
- apps/frontend/src/app/page.tsx +73 -0
- apps/frontend/src/assets/logo.png +3 -0
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
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
|
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 |
+
}
|