diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3387bff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.env +node_modules +dist +.turbo diff --git a/.gitignore b/.gitignore index 96fab4f..1beeaca 100644 --- a/.gitignore +++ b/.gitignore @@ -25,8 +25,12 @@ coverage .next/ out/ build -dist +dist/ +# Prisma +apps/api/prisma/migrations/ +apps/api/prisma/dev.db +apps/api/prisma/dev.db-journal # Debug npm-debug.log* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c0da5c2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Unreal Chat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 4b76073..f4bef9c 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,109 @@ -# Turborepo starter +# Unreal Chat -This Turborepo starter is maintained by the Turborepo core team. +A real-time chat application built with SolidJS, GraphQL, and Prisma. -## Using this example +## Project Structure -Run the following command: +This is a monorepo containing the following packages: -```sh -npx create-turbo@latest +- `apps/api`: GraphQL API server built with Apollo Server, GraphQL, and Prisma +- `apps/web`: Web client built with SolidJS and URQL GraphQL client + +## Features + +- Real-time chat with GraphQL subscriptions +- User authentication +- Chat room management +- Message sending and receiving +- Public and private chat rooms + +## Getting Started + +### Prerequisites + +- Node.js (v18 or later) +- npm (v10 or later) +- MariaDB or MySQL + +### Installation + +1. Clone the repository: + +```bash +git clone https://github.com/yourusername/unreal-chat.git +cd unreal-chat ``` -## What's inside? +2. Install dependencies: -This Turborepo includes the following packages/apps: - -### Apps and Packages - -- `docs`: a [Next.js](https://nextjs.org/) app -- `web`: another [Next.js](https://nextjs.org/) app -- `@repo/ui`: a stub React component library shared by both `web` and `docs` applications -- `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`) -- `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo - -Each package/app is 100% [TypeScript](https://www.typescriptlang.org/). - -### Utilities - -This Turborepo has some additional tools already setup for you: - -- [TypeScript](https://www.typescriptlang.org/) for static type checking -- [ESLint](https://eslint.org/) for code linting -- [Prettier](https://prettier.io) for code formatting - -### Build - -To build all apps and packages, run the following command: - -``` -cd my-turborepo -pnpm build +```bash +npm install ``` -### Develop +3. Set up environment variables: -To develop all apps and packages, run the following command: +Create a `.env` file in the `apps/api` directory: ``` -cd my-turborepo -pnpm dev +DATABASE_URL="mysql://root:password@localhost:3306/unreal_chat" +JWT_SECRET="your-secret-key" ``` -### Remote Caching - -> [!TIP] -> Vercel Remote Cache is free for all plans. Get started today at [vercel.com](https://vercel.com/signup?/signup?utm_source=remote-cache-sdk&utm_campaign=free_remote_cache). - -Turborepo can use a technique known as [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines. - -By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup?utm_source=turborepo-examples), then enter the following commands: +Create a `.env` file in the `apps/web` directory: ``` -cd my-turborepo -npx turbo login +VITE_API_URL=http://localhost:4000/graphql +VITE_WS_URL=ws://localhost:4000/graphql ``` -This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview). +4. Initialize the database: -Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo: - -``` -npx turbo link +```bash +npm run prisma:init ``` -## Useful Links +5. Start the development servers: -Learn more about the power of Turborepo: +```bash +# Start both API and web servers +npm run dev -- [Tasks](https://turbo.build/repo/docs/core-concepts/monorepos/running-tasks) -- [Caching](https://turbo.build/repo/docs/core-concepts/caching) -- [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching) -- [Filtering](https://turbo.build/repo/docs/core-concepts/monorepos/filtering) -- [Configuration Options](https://turbo.build/repo/docs/reference/configuration) -- [CLI Usage](https://turbo.build/repo/docs/reference/command-line-reference) +# Or start them individually +npm run api:dev +npm run web:dev +``` + +The API will be available at http://localhost:4000/graphql and the web app at http://localhost:5173. + +## Available Scripts + +- `npm run dev` - Start all development servers +- `npm run api:dev` - Start the API development server +- `npm run web:dev` - Start the web development server +- `npm run build` - Build all packages +- `npm run api:build` - Build the API +- `npm run web:build` - Build the web app +- `npm run prisma:generate` - Generate Prisma client +- `npm run prisma:migrate` - Run Prisma migrations +- `npm run prisma:studio` - Open Prisma Studio + +## Technologies + +### Backend (API) + +- Apollo Server +- GraphQL +- Prisma ORM +- MariaDB +- JSON Web Tokens (JWT) + +### Frontend (Web) + +- SolidJS +- URQL GraphQL Client +- GraphQL Subscriptions +- CSS + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/apps/api/.dockerignore b/apps/api/.dockerignore new file mode 100644 index 0000000..3387bff --- /dev/null +++ b/apps/api/.dockerignore @@ -0,0 +1,4 @@ +.env +node_modules +dist +.turbo diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 0000000..11ddd8d --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,3 @@ +node_modules +# Keep environment variables out of version control +.env diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..ed5801a --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-alpine AS base + +# Rebuild the source code only when needed +FROM base +WORKDIR /app +COPY . . +# Install dependencies +RUN npm install + +# Generate Prisma client +RUN npm run prisma:generate + +# Build the project +RUN npm run api:build + +# Start the server +ENTRYPOINT [ "npm", "run", "api:start" ] diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..92526f8 --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,91 @@ +# Unreal Chat API + +The backend API for the Unreal Chat application, built with Apollo Server, GraphQL, and Prisma. + +## Features + +- GraphQL API with Apollo Server +- Real-time subscriptions for messages and rooms +- Prisma ORM with MariaDB +- User authentication +- Chat rooms and messaging + +## Getting Started + +### Prerequisites + +- Node.js (v18 or later) +- npm (v10 or later) +- MariaDB or MySQL + +### Installation + +1. Install dependencies: + +```bash +npm install +``` + +2. Set up the database: + +Make sure you have MariaDB running and update the connection string in `prisma/.env` if needed. + +3. Run Prisma migrations: + +```bash +npm run prisma:migrate +``` + +4. Start the development server: + +```bash +npm run dev +``` + +The API will be available at http://localhost:4000/graphql. + +## Available Scripts + +- `npm run dev` - Start the development server +- `npm run build` - Build the application +- `npm run start` - Start the production server +- `npm run prisma:generate` - Generate Prisma client +- `npm run prisma:migrate` - Run Prisma migrations +- `npm run prisma:studio` - Open Prisma Studio + +## API Documentation + +### Authentication + +For development purposes, authentication is simplified. In a production environment, you would use JWT tokens. + +### GraphQL Schema + +The GraphQL schema includes the following main types: + +- `User`: Represents a user in the system +- `Room`: Represents a chat room +- `Message`: Represents a message in a chat room + +### Queries + +- `me`: Get the current user +- `users`: Get all users +- `user(id: ID!)`: Get a user by ID +- `rooms`: Get all public rooms +- `room(id: ID!)`: Get a room by ID +- `messages(roomId: ID!)`: Get messages in a room + +### Mutations + +- `register(email: String!, username: String!, password: String!)`: Register a new user +- `login(email: String!, password: String!)`: Login a user +- `createRoom(name: String!, description: String, isPrivate: Boolean)`: Create a new room +- `joinRoom(roomId: ID!)`: Join a room +- `leaveRoom(roomId: ID!)`: Leave a room +- `sendMessage(content: String!, roomId: ID!)`: Send a message to a room + +### Subscriptions + +- `messageAdded(roomId: ID!)`: Subscribe to new messages in a room +- `roomAdded`: Subscribe to new rooms diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..b943f96 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,44 @@ +{ + "name": "api", + "version": "1.0.0", + "main": "dist/index.js", + "scripts": { + "dev": "nodemon --exec ts-node src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:studio": "prisma studio", + "prisma:init": "prisma migrate dev --name init", + "check-types": "tsc --noEmit" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "@apollo/server": "^4.10.0", + "@graphql-tools/schema": "^10.0.2", + "@prisma/client": "^6.4.1", + "@types/ws": "^8.5.14", + "apollo-server": "^3.13.0", + "apollo-server-express": "^3.13.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.18.2", + "graphql": "^16.8.1", + "graphql-subscriptions": "^2.0.0", + "graphql-ws": "^5.14.0", + "subscriptions-transport-ws": "^0.11.0", + "ws": "^8.18.1" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.11.20", + "nodemon": "^3.1.0", + "prisma": "^6.4.1", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..fd806ca --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,50 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) + email String @unique + username String @unique + password String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + messages Message[] + rooms Room[] @relation("RoomMembers") + ownedRooms Room[] @relation("RoomOwner") +} + +model Room { + id String @id @default(uuid()) + name String + description String? + isPrivate Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + messages Message[] + members User[] @relation("RoomMembers") + ownerId String + owner User @relation("RoomOwner", fields: [ownerId], references: [id]) +} + +model Message { + id String @id @default(uuid()) + content String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + userId String + user User @relation(fields: [userId], references: [id]) + roomId String + room Room @relation(fields: [roomId], references: [id]) +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..3c17dc0 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,93 @@ +import { ApolloServer } from '@apollo/server'; +import { expressMiddleware } from '@apollo/server/express4'; +import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'; +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { createServer } from 'http'; +import express from 'express'; +import { WebSocketServer } from 'ws'; +import { useServer } from 'graphql-ws/lib/use/ws'; +import cors from 'cors'; +import { PrismaClient } from '@prisma/client'; +import { typeDefs } from './schema/typeDefs'; +import { resolvers } from './resolvers'; +import dotenv from 'dotenv'; + +// Load environment variables +dotenv.config(); + +// Create Prisma client +const prisma = new PrismaClient(); + +async function startServer() { + // Create Express app and HTTP server + const app = express(); + const httpServer = createServer(app); + + // Create WebSocket server + const wsServer = new WebSocketServer({ + server: httpServer, + path: '/graphql', + }); + + // Create schema + const schema = makeExecutableSchema({ typeDefs, resolvers }); + + // Set up WebSocket server + const serverCleanup = useServer({ schema }, wsServer); + + // Create Apollo Server + const server = new ApolloServer({ + schema, + plugins: [ + // Proper shutdown for the HTTP server + ApolloServerPluginDrainHttpServer({ httpServer }), + // Proper shutdown for the WebSocket server + { + async serverWillStart() { + return { + async drainServer() { + await serverCleanup.dispose(); + }, + }; + }, + }, + ], + }); + + // Start Apollo Server + await server.start(); + + // Apply middleware + app.use( + '/graphql', + cors(), + express.json(), + expressMiddleware(server, { + context: async ({ req }) => { + // In a real application, you would extract the user ID from a JWT token + // const token = req.headers.authorization || ''; + // const userId = getUserIdFromToken(token); + + // For demo purposes, we'll use a dummy user ID + const userId = (req.headers['user-id'] as string) || null; + + return { + prisma, + userId, + }; + }, + }) + ); + + // Start the server + const PORT = process.env.PORT || 4000; + httpServer.listen(PORT, () => { + console.log(`🚀 Server ready at http://localhost:${PORT}/graphql`); + console.log(`🚀 Subscriptions ready at ws://localhost:${PORT}/graphql`); + }); +} + +// Handle errors +startServer().catch((err) => { + console.error('Error starting server:', err); +}); diff --git a/apps/api/src/resolvers/index.ts b/apps/api/src/resolvers/index.ts new file mode 100644 index 0000000..cdf3271 --- /dev/null +++ b/apps/api/src/resolvers/index.ts @@ -0,0 +1,23 @@ +import { userResolvers } from './user'; +import { roomResolvers } from './room'; +import { messageResolvers } from './message'; + +export const resolvers = { + Query: { + ...userResolvers.Query, + ...roomResolvers.Query, + ...messageResolvers.Query, + }, + Mutation: { + ...userResolvers.Mutation, + ...roomResolvers.Mutation, + ...messageResolvers.Mutation, + }, + Subscription: { + ...messageResolvers.Subscription, + ...roomResolvers.Subscription, + }, + User: userResolvers.User, + Room: roomResolvers.Room, + Message: messageResolvers.Message, +}; diff --git a/apps/api/src/resolvers/message.ts b/apps/api/src/resolvers/message.ts new file mode 100644 index 0000000..a32da7c --- /dev/null +++ b/apps/api/src/resolvers/message.ts @@ -0,0 +1,112 @@ +import { PrismaClient } from '@prisma/client'; +import { AuthenticationError, ForbiddenError } from 'apollo-server-express'; +import { PubSub, withFilter } from 'graphql-subscriptions'; + +const prisma = new PrismaClient(); +const pubsub = new PubSub(); + +export const MESSAGE_ADDED = 'MESSAGE_ADDED'; + +export const messageResolvers = { + Query: { + messages: async (_: any, { roomId }: { roomId: string }, context: any) => { + if (!context.userId) { + throw new AuthenticationError('You must be logged in to view messages'); + } + + // Check if user is a member of the room + const room = await prisma.room.findUnique({ + where: { id: roomId }, + include: { members: true }, + }); + + if (!room) { + throw new ForbiddenError('Room not found'); + } + + const isMember = room.members.some( + (member: { id: string }) => member.id === context.userId + ); + if (!isMember) { + throw new ForbiddenError('You are not a member of this room'); + } + + return prisma.message.findMany({ + where: { roomId }, + orderBy: { createdAt: 'asc' }, + }); + }, + }, + Mutation: { + sendMessage: async ( + _: any, + { content, roomId }: { content: string; roomId: string }, + context: any + ) => { + if (!context.userId) { + throw new AuthenticationError( + 'You must be logged in to send a message' + ); + } + + // Check if user is a member of the room + const room = await prisma.room.findUnique({ + where: { id: roomId }, + include: { members: true }, + }); + + if (!room) { + throw new ForbiddenError('Room not found'); + } + + const isMember = room.members.some( + (member: { id: string }) => member.id === context.userId + ); + if (!isMember) { + throw new ForbiddenError('You are not a member of this room'); + } + + const message = await prisma.message.create({ + data: { + content, + user: { + connect: { id: context.userId }, + }, + room: { + connect: { id: roomId }, + }, + }, + include: { + user: true, + room: true, + }, + }); + + pubsub.publish(MESSAGE_ADDED, { messageAdded: message, roomId }); + + return message; + }, + }, + Subscription: { + messageAdded: { + subscribe: withFilter( + () => pubsub.asyncIterator([MESSAGE_ADDED]), + (payload, variables) => { + return payload.roomId === variables.roomId; + } + ), + }, + }, + Message: { + user: async (parent: any) => { + return prisma.user.findUnique({ + where: { id: parent.userId }, + }); + }, + room: async (parent: any) => { + return prisma.room.findUnique({ + where: { id: parent.roomId }, + }); + }, + }, +}; diff --git a/apps/api/src/resolvers/room.ts b/apps/api/src/resolvers/room.ts new file mode 100644 index 0000000..2fbc998 --- /dev/null +++ b/apps/api/src/resolvers/room.ts @@ -0,0 +1,161 @@ +import { PrismaClient } from '@prisma/client'; +import { AuthenticationError, ForbiddenError } from 'apollo-server-express'; +import { PubSub } from 'graphql-subscriptions'; + +const prisma = new PrismaClient(); +const pubsub = new PubSub(); + +export const ROOM_ADDED = 'ROOM_ADDED'; + +export const roomResolvers = { + Query: { + rooms: async () => { + return prisma.room.findMany({ + where: { isPrivate: false }, + }); + }, + room: async (_: any, { id }: { id: string }) => { + return prisma.room.findUnique({ + where: { id }, + }); + }, + }, + Mutation: { + createRoom: async ( + _: any, + { + name, + description, + isPrivate = false, + }: { name: string; description?: string; isPrivate?: boolean }, + context: any + ) => { + if (!context.userId) { + throw new AuthenticationError('You must be logged in to create a room'); + } + + const room = await prisma.room.create({ + data: { + name, + description, + isPrivate, + owner: { + connect: { id: context.userId }, + }, + members: { + connect: { id: context.userId }, + }, + }, + }); + + pubsub.publish(ROOM_ADDED, { roomAdded: room }); + + return room; + }, + joinRoom: async (_: any, { roomId }: { roomId: string }, context: any) => { + if (!context.userId) { + throw new AuthenticationError('You must be logged in to join a room'); + } + + const room = await prisma.room.findUnique({ + where: { id: roomId }, + include: { members: true }, + }); + + if (!room) { + throw new ForbiddenError('Room not found'); + } + + if (room.isPrivate) { + // In a real application, you would check if the user has been invited + throw new ForbiddenError( + 'You cannot join a private room without an invitation' + ); + } + + // Check if user is already a member + const isMember = room.members.some( + (member: { id: string }) => member.id === context.userId + ); + if (isMember) { + return room; + } + + return prisma.room.update({ + where: { id: roomId }, + data: { + members: { + connect: { id: context.userId }, + }, + }, + }); + }, + leaveRoom: async (_: any, { roomId }: { roomId: string }, context: any) => { + if (!context.userId) { + throw new AuthenticationError('You must be logged in to leave a room'); + } + + const room = await prisma.room.findUnique({ + where: { id: roomId }, + include: { members: true }, + }); + + if (!room) { + throw new ForbiddenError('Room not found'); + } + + // Check if user is a member + const isMember = room.members.some( + (member: { id: string }) => member.id === context.userId + ); + if (!isMember) { + throw new ForbiddenError('You are not a member of this room'); + } + + // If user is the owner, they cannot leave + if (room.ownerId === context.userId) { + throw new ForbiddenError('You cannot leave a room you own'); + } + + await prisma.room.update({ + where: { id: roomId }, + data: { + members: { + disconnect: { id: context.userId }, + }, + }, + }); + + return true; + }, + }, + Subscription: { + roomAdded: { + subscribe: () => pubsub.asyncIterator([ROOM_ADDED]), + }, + }, + Room: { + messages: async (parent: any) => { + return prisma.message.findMany({ + where: { roomId: parent.id }, + orderBy: { createdAt: 'asc' }, + }); + }, + members: async (parent: any) => { + return prisma.user.findMany({ + where: { + rooms: { + some: { + id: parent.id, + }, + }, + }, + }); + }, + owner: async (parent: any) => { + return prisma.user.findUnique({ + where: { id: parent.ownerId }, + }); + }, + }, +}; diff --git a/apps/api/src/resolvers/user.ts b/apps/api/src/resolvers/user.ts new file mode 100644 index 0000000..0fd7538 --- /dev/null +++ b/apps/api/src/resolvers/user.ts @@ -0,0 +1,121 @@ +import { PrismaClient } from '@prisma/client'; +import { AuthenticationError, UserInputError } from 'apollo-server-express'; +// In a real application, you would use bcrypt for password hashing +// import bcrypt from 'bcryptjs'; +// import jwt from 'jsonwebtoken'; + +const prisma = new PrismaClient(); + +export const userResolvers = { + Query: { + me: async (_: any, __: any, context: any) => { + // In a real application, you would get the user from the context + // which would be set by an authentication middleware + if (!context.userId) { + return null; + } + return prisma.user.findUnique({ + where: { id: context.userId }, + }); + }, + users: async () => { + return prisma.user.findMany(); + }, + user: async (_: any, { id }: { id: string }) => { + return prisma.user.findUnique({ + where: { id }, + }); + }, + }, + Mutation: { + register: async ( + _: any, + { + email, + username, + password, + }: { email: string; username: string; password: string } + ) => { + // Check if user already exists + const existingUser = await prisma.user.findFirst({ + where: { + OR: [{ email }, { username }], + }, + }); + + if (existingUser) { + throw new UserInputError('User already exists'); + } + + // In a real application, you would hash the password + // const hashedPassword = await bcrypt.hash(password, 10); + + const user = await prisma.user.create({ + data: { + email, + username, + password, // In a real app: hashedPassword + }, + }); + + // In a real application, you would generate a JWT token + // const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET); + + return { + token: 'dummy-token', // In a real app: token + user, + }; + }, + login: async ( + _: any, + { email, password }: { email: string; password: string } + ) => { + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + throw new AuthenticationError('Invalid credentials'); + } + + // In a real application, you would verify the password + // const valid = await bcrypt.compare(password, user.password); + const valid = password === user.password; // This is just for demo purposes + + if (!valid) { + throw new AuthenticationError('Invalid credentials'); + } + + // In a real application, you would generate a JWT token + // const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET); + + return { + token: 'dummy-token', // In a real app: token + user, + }; + }, + }, + User: { + messages: async (parent: any) => { + return prisma.message.findMany({ + where: { userId: parent.id }, + }); + }, + rooms: async (parent: any) => { + return prisma.room.findMany({ + where: { + members: { + some: { + id: parent.id, + }, + }, + }, + }); + }, + ownedRooms: async (parent: any) => { + return prisma.room.findMany({ + where: { ownerId: parent.id }, + }); + }, + }, +}; diff --git a/apps/api/src/schema/typeDefs.ts b/apps/api/src/schema/typeDefs.ts new file mode 100644 index 0000000..1a58fda --- /dev/null +++ b/apps/api/src/schema/typeDefs.ts @@ -0,0 +1,63 @@ +import { gql } from 'apollo-server-express'; + +export const typeDefs = gql` + type User { + id: ID! + email: String! + username: String! + createdAt: String! + updatedAt: String! + messages: [Message!] + rooms: [Room!] + ownedRooms: [Room!] + } + + type Room { + id: ID! + name: String! + description: String + isPrivate: Boolean! + createdAt: String! + updatedAt: String! + messages: [Message!] + members: [User!] + owner: User! + } + + type Message { + id: ID! + content: String! + createdAt: String! + updatedAt: String! + user: User! + room: Room! + } + + type AuthPayload { + token: String! + user: User! + } + + type Query { + me: User + users: [User!]! + user(id: ID!): User + rooms: [Room!]! + room(id: ID!): Room + messages(roomId: ID!): [Message!]! + } + + type Mutation { + register(email: String!, username: String!, password: String!): AuthPayload! + login(email: String!, password: String!): AuthPayload! + createRoom(name: String!, description: String, isPrivate: Boolean): Room! + joinRoom(roomId: ID!): Room! + leaveRoom(roomId: ID!): Boolean! + sendMessage(content: String!, roomId: ID!): Message! + } + + type Subscription { + messageAdded(roomId: ID!): Message! + roomAdded: Room! + } +`; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..7846fa0 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2018", + "module": "commonjs", + "lib": ["es2018", "esnext.asynciterable"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore deleted file mode 100644 index f886745..0000000 --- a/apps/docs/.gitignore +++ /dev/null @@ -1,36 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# env files (can opt-in for commiting if needed) -.env* - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts diff --git a/apps/docs/README.md b/apps/docs/README.md deleted file mode 100644 index a98bfa8..0000000 --- a/apps/docs/README.md +++ /dev/null @@ -1,36 +0,0 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/docs/app/favicon.ico b/apps/docs/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/apps/docs/app/favicon.ico and /dev/null differ diff --git a/apps/docs/app/fonts/GeistMonoVF.woff b/apps/docs/app/fonts/GeistMonoVF.woff deleted file mode 100644 index f2ae185..0000000 Binary files a/apps/docs/app/fonts/GeistMonoVF.woff and /dev/null differ diff --git a/apps/docs/app/fonts/GeistVF.woff b/apps/docs/app/fonts/GeistVF.woff deleted file mode 100644 index 1b62daa..0000000 Binary files a/apps/docs/app/fonts/GeistVF.woff and /dev/null differ diff --git a/apps/docs/app/globals.css b/apps/docs/app/globals.css deleted file mode 100644 index 6af7ecb..0000000 --- a/apps/docs/app/globals.css +++ /dev/null @@ -1,50 +0,0 @@ -:root { - --background: #ffffff; - --foreground: #171717; -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -html, -body { - max-width: 100vw; - overflow-x: hidden; -} - -body { - color: var(--foreground); - background: var(--background); -} - -* { - box-sizing: border-box; - padding: 0; - margin: 0; -} - -a { - color: inherit; - text-decoration: none; -} - -.imgDark { - display: none; -} - -@media (prefers-color-scheme: dark) { - html { - color-scheme: dark; - } - - .imgLight { - display: none; - } - .imgDark { - display: unset; - } -} diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx deleted file mode 100644 index 8469537..0000000 --- a/apps/docs/app/layout.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Metadata } from "next"; -import localFont from "next/font/local"; -import "./globals.css"; - -const geistSans = localFont({ - src: "./fonts/GeistVF.woff", - variable: "--font-geist-sans", -}); -const geistMono = localFont({ - src: "./fonts/GeistMonoVF.woff", - variable: "--font-geist-mono", -}); - -export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - {children} - - - ); -} diff --git a/apps/docs/app/page.module.css b/apps/docs/app/page.module.css deleted file mode 100644 index 3630662..0000000 --- a/apps/docs/app/page.module.css +++ /dev/null @@ -1,188 +0,0 @@ -.page { - --gray-rgb: 0, 0, 0; - --gray-alpha-200: rgba(var(--gray-rgb), 0.08); - --gray-alpha-100: rgba(var(--gray-rgb), 0.05); - - --button-primary-hover: #383838; - --button-secondary-hover: #f2f2f2; - - display: grid; - grid-template-rows: 20px 1fr 20px; - align-items: center; - justify-items: center; - min-height: 100svh; - padding: 80px; - gap: 64px; - font-synthesis: none; -} - -@media (prefers-color-scheme: dark) { - .page { - --gray-rgb: 255, 255, 255; - --gray-alpha-200: rgba(var(--gray-rgb), 0.145); - --gray-alpha-100: rgba(var(--gray-rgb), 0.06); - - --button-primary-hover: #ccc; - --button-secondary-hover: #1a1a1a; - } -} - -.main { - display: flex; - flex-direction: column; - gap: 32px; - grid-row-start: 2; -} - -.main ol { - font-family: var(--font-geist-mono); - padding-left: 0; - margin: 0; - font-size: 14px; - line-height: 24px; - letter-spacing: -0.01em; - list-style-position: inside; -} - -.main li:not(:last-of-type) { - margin-bottom: 8px; -} - -.main code { - font-family: inherit; - background: var(--gray-alpha-100); - padding: 2px 4px; - border-radius: 4px; - font-weight: 600; -} - -.ctas { - display: flex; - gap: 16px; -} - -.ctas a { - appearance: none; - border-radius: 128px; - height: 48px; - padding: 0 20px; - border: none; - font-family: var(--font-geist-sans); - border: 1px solid transparent; - transition: background 0.2s, color 0.2s, border-color 0.2s; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - line-height: 20px; - font-weight: 500; -} - -a.primary { - background: var(--foreground); - color: var(--background); - gap: 8px; -} - -a.secondary { - border-color: var(--gray-alpha-200); - min-width: 180px; -} - -button.secondary { - appearance: none; - border-radius: 128px; - height: 48px; - padding: 0 20px; - border: none; - font-family: var(--font-geist-sans); - border: 1px solid transparent; - transition: background 0.2s, color 0.2s, border-color 0.2s; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - line-height: 20px; - font-weight: 500; - background: transparent; - border-color: var(--gray-alpha-200); - min-width: 180px; -} - -.footer { - font-family: var(--font-geist-sans); - grid-row-start: 3; - display: flex; - gap: 24px; -} - -.footer a { - display: flex; - align-items: center; - gap: 8px; -} - -.footer img { - flex-shrink: 0; -} - -/* Enable hover only on non-touch devices */ -@media (hover: hover) and (pointer: fine) { - a.primary:hover { - background: var(--button-primary-hover); - border-color: transparent; - } - - a.secondary:hover { - background: var(--button-secondary-hover); - border-color: transparent; - } - - .footer a:hover { - text-decoration: underline; - text-underline-offset: 4px; - } -} - -@media (max-width: 600px) { - .page { - padding: 32px; - padding-bottom: 80px; - } - - .main { - align-items: center; - } - - .main ol { - text-align: center; - } - - .ctas { - flex-direction: column; - } - - .ctas a { - font-size: 14px; - height: 40px; - padding: 0 16px; - } - - a.secondary { - min-width: auto; - } - - .footer { - flex-wrap: wrap; - align-items: center; - justify-content: center; - } -} - -@media (prefers-color-scheme: dark) { - .logo { - filter: invert(); - } -} diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx deleted file mode 100644 index 828709a..0000000 --- a/apps/docs/app/page.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import Image, { type ImageProps } from "next/image"; -import { Button } from "@repo/ui/button"; -import styles from "./page.module.css"; - -type Props = Omit & { - srcLight: string; - srcDark: string; -}; - -const ThemeImage = (props: Props) => { - const { srcLight, srcDark, ...rest } = props; - - return ( - <> - - - - ); -}; - -export default function Home() { - return ( -
-
- -
    -
  1. - Get started by editing apps/docs/app/page.tsx -
  2. -
  3. Save and see your changes instantly.
  4. -
- - - -
- -
- ); -} diff --git a/apps/docs/eslint.config.js b/apps/docs/eslint.config.js deleted file mode 100644 index e8759ff..0000000 --- a/apps/docs/eslint.config.js +++ /dev/null @@ -1,4 +0,0 @@ -import { nextJsConfig } from "@repo/eslint-config/next-js"; - -/** @type {import("eslint").Linter.Config} */ -export default nextJsConfig; diff --git a/apps/docs/next.config.js b/apps/docs/next.config.js deleted file mode 100644 index 4678774..0000000 --- a/apps/docs/next.config.js +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = {}; - -export default nextConfig; diff --git a/apps/docs/package.json b/apps/docs/package.json deleted file mode 100644 index 2f453bb..0000000 --- a/apps/docs/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "docs", - "version": "0.1.0", - "type": "module", - "private": true, - "scripts": { - "dev": "next dev --turbopack --port 3001", - "build": "next build", - "start": "next start", - "lint": "next lint --max-warnings 0", - "check-types": "tsc --noEmit" - }, - "dependencies": { - "@repo/ui": "*", - "next": "^15.2.1", - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "devDependencies": { - "@repo/eslint-config": "*", - "@repo/typescript-config": "*", - "@types/node": "^22.13.9", - "@types/react": "19.0.10", - "@types/react-dom": "19.0.4", - "eslint": "^9.21.0", - "typescript": "5.8.2" - } -} diff --git a/apps/docs/public/file-text.svg b/apps/docs/public/file-text.svg deleted file mode 100644 index 9cfb3c9..0000000 --- a/apps/docs/public/file-text.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/docs/public/globe.svg b/apps/docs/public/globe.svg deleted file mode 100644 index 4230a3d..0000000 --- a/apps/docs/public/globe.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/apps/docs/public/next.svg b/apps/docs/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/apps/docs/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/docs/public/turborepo-dark.svg b/apps/docs/public/turborepo-dark.svg deleted file mode 100644 index dae38fe..0000000 --- a/apps/docs/public/turborepo-dark.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/apps/docs/public/turborepo-light.svg b/apps/docs/public/turborepo-light.svg deleted file mode 100644 index ddea915..0000000 --- a/apps/docs/public/turborepo-light.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/apps/docs/public/vercel.svg b/apps/docs/public/vercel.svg deleted file mode 100644 index 0164ddc..0000000 --- a/apps/docs/public/vercel.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/apps/docs/public/window.svg b/apps/docs/public/window.svg deleted file mode 100644 index bbc7800..0000000 --- a/apps/docs/public/window.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json deleted file mode 100644 index 7aef056..0000000 --- a/apps/docs/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "@repo/typescript-config/nextjs.json", - "compilerOptions": { - "plugins": [ - { - "name": "next" - } - ] - }, - "include": [ - "**/*.ts", - "**/*.tsx", - "next-env.d.ts", - "next.config.js", - ".next/types/**/*.ts" - ], - "exclude": [ - "node_modules" - ] -} diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000..3387bff --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,4 @@ +.env +node_modules +dist +.turbo diff --git a/apps/web/.gitignore b/apps/web/.gitignore index f886745..a547bf3 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -1,36 +1,24 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug +# Logs +logs +*.log npm-debug.log* yarn-debug.log* yarn-error.log* +pnpm-debug.log* +lerna-debug.log* -# env files (can opt-in for commiting if needed) -.env* +node_modules +dist +dist-ssr +*.local -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..fe5b8b8 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,44 @@ +FROM node:22-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ +COPY apps/web/package.json ./apps/web/package.json + +# Install dependencies +RUN npm ci + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Build the project +ARG NODE_ENV +ARG VITE_API_URL +ARG VITE_WS_URL +RUN npm run web:build + +# Production image, copy all the files and run the server +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production + +# Copy necessary files +COPY --from=builder /app/apps/web/dist ./dist +COPY --from=builder /app/apps/web/package.json ./package.json +COPY --from=builder /app/node_modules ./node_modules + +# Install serve to run the application +RUN npm install -g serve + +# Expose the port +EXPOSE 5173 + +# Start the server +CMD serve -s dist -l 5173 \ No newline at end of file diff --git a/apps/web/README.md b/apps/web/README.md index a98bfa8..d35f32f 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,36 +1,104 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app). +# Unreal Chat Web + +The frontend web application for Unreal Chat, built with SolidJS and URQL GraphQL client. + +## Features + +- Real-time chat with GraphQL subscriptions +- User authentication +- Chat room management +- Message sending and receiving +- Responsive design ## Getting Started -First, run the development server: +### Prerequisites + +- Node.js (v18 or later) +- npm (v10 or later) + +### Installation + +1. Install dependencies: + +```bash +npm install +``` + +2. Set up environment variables: + +Create a `.env` file in the root directory with the following variables: + +``` +VITE_API_URL=http://localhost:4000/graphql +VITE_WS_URL=ws://localhost:4000/graphql +``` + +3. Start the development server: ```bash npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +The web app will be available at http://localhost:5173. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Available Scripts -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font. +- `npm run dev` - Start the development server +- `npm run build` - Build the application +- `npm run preview` - Preview the built application +- `npm run check-types` - Check TypeScript types -## Learn More +## Project Structure -To learn more about Next.js, take a look at the following resources: +``` +src/ +├── components/ # UI components +├── lib/ # Utilities and shared code +├── types/ # TypeScript type definitions +├── App.tsx # Main application component +└── index.tsx # Application entry point +``` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## Components -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +- `LoginForm`: User login form +- `RegisterForm`: User registration form +- `RoomList`: List of available chat rooms +- `ChatRoom`: Chat room with messages +- `CreateRoom`: Form to create a new chat room -## Deploy on Vercel +## GraphQL Client -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +The application uses URQL as the GraphQL client with subscription support. The client is configured in `src/lib/graphql-client.ts`. -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +## Authentication + +Authentication is handled using a simple token-based approach. The token is stored in localStorage and included in GraphQL requests. + +```bash +$ npm install # or pnpm install or yarn install +``` + +### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) + +## Available Scripts + +In the project directory, you can run: + +### `npm run dev` + +Runs the app in the development mode.
+Open [http://localhost:5173](http://localhost:5173) to view it in the browser. + +### `npm run build` + +Builds the app for production to the `dist` folder.
+It correctly bundles Solid in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
+Your app is ready to be deployed! + +## Deployment + +Learn more about deploying your application with the [documentations](https://vite.dev/guide/static-deploy.html) diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/apps/web/app/favicon.ico and /dev/null differ diff --git a/apps/web/app/fonts/GeistMonoVF.woff b/apps/web/app/fonts/GeistMonoVF.woff deleted file mode 100644 index f2ae185..0000000 Binary files a/apps/web/app/fonts/GeistMonoVF.woff and /dev/null differ diff --git a/apps/web/app/fonts/GeistVF.woff b/apps/web/app/fonts/GeistVF.woff deleted file mode 100644 index 1b62daa..0000000 Binary files a/apps/web/app/fonts/GeistVF.woff and /dev/null differ diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css deleted file mode 100644 index 6af7ecb..0000000 --- a/apps/web/app/globals.css +++ /dev/null @@ -1,50 +0,0 @@ -:root { - --background: #ffffff; - --foreground: #171717; -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -html, -body { - max-width: 100vw; - overflow-x: hidden; -} - -body { - color: var(--foreground); - background: var(--background); -} - -* { - box-sizing: border-box; - padding: 0; - margin: 0; -} - -a { - color: inherit; - text-decoration: none; -} - -.imgDark { - display: none; -} - -@media (prefers-color-scheme: dark) { - html { - color-scheme: dark; - } - - .imgLight { - display: none; - } - .imgDark { - display: unset; - } -} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx deleted file mode 100644 index 8469537..0000000 --- a/apps/web/app/layout.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Metadata } from "next"; -import localFont from "next/font/local"; -import "./globals.css"; - -const geistSans = localFont({ - src: "./fonts/GeistVF.woff", - variable: "--font-geist-sans", -}); -const geistMono = localFont({ - src: "./fonts/GeistMonoVF.woff", - variable: "--font-geist-mono", -}); - -export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - {children} - - - ); -} diff --git a/apps/web/app/page.module.css b/apps/web/app/page.module.css deleted file mode 100644 index 3630662..0000000 --- a/apps/web/app/page.module.css +++ /dev/null @@ -1,188 +0,0 @@ -.page { - --gray-rgb: 0, 0, 0; - --gray-alpha-200: rgba(var(--gray-rgb), 0.08); - --gray-alpha-100: rgba(var(--gray-rgb), 0.05); - - --button-primary-hover: #383838; - --button-secondary-hover: #f2f2f2; - - display: grid; - grid-template-rows: 20px 1fr 20px; - align-items: center; - justify-items: center; - min-height: 100svh; - padding: 80px; - gap: 64px; - font-synthesis: none; -} - -@media (prefers-color-scheme: dark) { - .page { - --gray-rgb: 255, 255, 255; - --gray-alpha-200: rgba(var(--gray-rgb), 0.145); - --gray-alpha-100: rgba(var(--gray-rgb), 0.06); - - --button-primary-hover: #ccc; - --button-secondary-hover: #1a1a1a; - } -} - -.main { - display: flex; - flex-direction: column; - gap: 32px; - grid-row-start: 2; -} - -.main ol { - font-family: var(--font-geist-mono); - padding-left: 0; - margin: 0; - font-size: 14px; - line-height: 24px; - letter-spacing: -0.01em; - list-style-position: inside; -} - -.main li:not(:last-of-type) { - margin-bottom: 8px; -} - -.main code { - font-family: inherit; - background: var(--gray-alpha-100); - padding: 2px 4px; - border-radius: 4px; - font-weight: 600; -} - -.ctas { - display: flex; - gap: 16px; -} - -.ctas a { - appearance: none; - border-radius: 128px; - height: 48px; - padding: 0 20px; - border: none; - font-family: var(--font-geist-sans); - border: 1px solid transparent; - transition: background 0.2s, color 0.2s, border-color 0.2s; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - line-height: 20px; - font-weight: 500; -} - -a.primary { - background: var(--foreground); - color: var(--background); - gap: 8px; -} - -a.secondary { - border-color: var(--gray-alpha-200); - min-width: 180px; -} - -button.secondary { - appearance: none; - border-radius: 128px; - height: 48px; - padding: 0 20px; - border: none; - font-family: var(--font-geist-sans); - border: 1px solid transparent; - transition: background 0.2s, color 0.2s, border-color 0.2s; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - line-height: 20px; - font-weight: 500; - background: transparent; - border-color: var(--gray-alpha-200); - min-width: 180px; -} - -.footer { - font-family: var(--font-geist-sans); - grid-row-start: 3; - display: flex; - gap: 24px; -} - -.footer a { - display: flex; - align-items: center; - gap: 8px; -} - -.footer img { - flex-shrink: 0; -} - -/* Enable hover only on non-touch devices */ -@media (hover: hover) and (pointer: fine) { - a.primary:hover { - background: var(--button-primary-hover); - border-color: transparent; - } - - a.secondary:hover { - background: var(--button-secondary-hover); - border-color: transparent; - } - - .footer a:hover { - text-decoration: underline; - text-underline-offset: 4px; - } -} - -@media (max-width: 600px) { - .page { - padding: 32px; - padding-bottom: 80px; - } - - .main { - align-items: center; - } - - .main ol { - text-align: center; - } - - .ctas { - flex-direction: column; - } - - .ctas a { - font-size: 14px; - height: 40px; - padding: 0 16px; - } - - a.secondary { - min-width: auto; - } - - .footer { - flex-wrap: wrap; - align-items: center; - justify-content: center; - } -} - -@media (prefers-color-scheme: dark) { - .logo { - filter: invert(); - } -} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx deleted file mode 100644 index b509205..0000000 --- a/apps/web/app/page.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import Image, { type ImageProps } from "next/image"; -import { Button } from "@repo/ui/button"; -import styles from "./page.module.css"; - -type Props = Omit & { - srcLight: string; - srcDark: string; -}; - -const ThemeImage = (props: Props) => { - const { srcLight, srcDark, ...rest } = props; - - return ( - <> - - - - ); -}; - -export default function Home() { - return ( -
-
- -
    -
  1. - Get started by editing apps/web/app/page.tsx -
  2. -
  3. Save and see your changes instantly.
  4. -
- - - -
- -
- ); -} diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js deleted file mode 100644 index e8759ff..0000000 --- a/apps/web/eslint.config.js +++ /dev/null @@ -1,4 +0,0 @@ -import { nextJsConfig } from "@repo/eslint-config/next-js"; - -/** @type {import("eslint").Linter.Config} */ -export default nextJsConfig; diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..7021737 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + Solid + TS + + +
+ + + diff --git a/apps/web/next.config.js b/apps/web/next.config.js deleted file mode 100644 index 4678774..0000000 --- a/apps/web/next.config.js +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = {}; - -export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 4f701dd..9fc3147 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,28 +1,24 @@ { "name": "web", - "version": "0.1.0", - "type": "module", "private": true, + "version": "0.0.0", + "type": "module", "scripts": { - "dev": "next dev --turbopack --port 3000", - "build": "next build", - "start": "next start", - "lint": "next lint --max-warnings 0", + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", "check-types": "tsc --noEmit" }, "dependencies": { - "@repo/ui": "*", - "next": "^15.2.1", - "react": "^19.0.0", - "react-dom": "^19.0.0" + "@urql/core": "^5.1.1", + "graphql": "^16.8.1", + "graphql-ws": "^6.0.4", + "solid-js": "^1.8.15", + "@urql/solid": "^0.1.2" }, "devDependencies": { - "@repo/eslint-config": "*", - "@repo/typescript-config": "*", - "@types/node": "^22.13.9", - "@types/react": "19.0.10", - "@types/react-dom": "19.0.4", - "eslint": "^9.21.0", - "typescript": "5.8.2" + "typescript": "^5.2.2", + "vite": "^5.1.4", + "vite-plugin-solid": "^2.10.1" } } diff --git a/apps/web/public/file-text.svg b/apps/web/public/file-text.svg deleted file mode 100644 index 9cfb3c9..0000000 --- a/apps/web/public/file-text.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/public/globe.svg b/apps/web/public/globe.svg deleted file mode 100644 index 4230a3d..0000000 --- a/apps/web/public/globe.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/apps/web/public/next.svg b/apps/web/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/apps/web/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/turborepo-dark.svg b/apps/web/public/turborepo-dark.svg deleted file mode 100644 index dae38fe..0000000 --- a/apps/web/public/turborepo-dark.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/turborepo-light.svg b/apps/web/public/turborepo-light.svg deleted file mode 100644 index ddea915..0000000 --- a/apps/web/public/turborepo-light.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/vercel.svg b/apps/web/public/vercel.svg deleted file mode 100644 index 0164ddc..0000000 --- a/apps/web/public/vercel.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/apps/web/public/vite.svg b/apps/web/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/web/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/window.svg b/apps/web/public/window.svg deleted file mode 100644 index bbc7800..0000000 --- a/apps/web/public/window.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/src/App.css b/apps/web/src/App.css new file mode 100644 index 0000000..d361b00 --- /dev/null +++ b/apps/web/src/App.css @@ -0,0 +1,367 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.solid:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} + +:root { + --primary-color: #4f46e5; + --primary-hover: #4338ca; + --secondary-color: #6b7280; + --background-color: #f9fafb; + --card-background: #ffffff; + --text-color: #1f2937; + --border-color: #e5e7eb; + --error-color: #ef4444; + --success-color: #10b981; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: + 'Inter', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + Cantarell, + 'Open Sans', + 'Helvetica Neue', + sans-serif; + color: var(--text-color); + background-color: var(--background-color); + line-height: 1.5; +} + +.app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2rem; + background-color: var(--primary-color); + color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.app-main { + flex: 1; + padding: 1rem; +} + +/* Auth styles */ +.auth-container { + max-width: 400px; + margin: 2rem auto; + padding: 2rem; + background-color: var(--card-background); + border-radius: 0.5rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.auth-tabs { + display: flex; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.auth-tabs button { + flex: 1; + padding: 0.75rem; + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + color: var(--secondary-color); +} + +.auth-tabs button.active { + color: var(--primary-color); + border-bottom: 2px solid var(--primary-color); +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + font-size: 1rem; +} + +.form-group.checkbox { + display: flex; + align-items: center; +} + +.form-group.checkbox label { + display: flex; + align-items: center; + margin-bottom: 0; +} + +.form-group.checkbox input { + width: auto; + margin-right: 0.5rem; +} + +button { + padding: 0.75rem 1.5rem; + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 0.25rem; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s; +} + +button:hover { + background-color: var(--primary-hover); +} + +button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.error { + color: var(--error-color); + margin-bottom: 1rem; + padding: 0.5rem; + background-color: rgba(239, 68, 68, 0.1); + border-radius: 0.25rem; +} + +.logout-button { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +/* Chat styles */ +.chat-container { + display: flex; + height: calc(100vh - 120px); + background-color: var(--card-background); + border-radius: 0.5rem; + overflow: hidden; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.sidebar { + width: 300px; + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; +} + +.room-list { + flex: 1; + overflow-y: auto; + padding: 1rem; +} + +.room-list h2 { + margin-bottom: 1rem; + font-size: 1.25rem; +} + +.room-item { + padding: 0.75rem; + border-radius: 0.25rem; + cursor: pointer; + margin-bottom: 0.5rem; + transition: background-color 0.2s; +} + +.room-item:hover { + background-color: rgba(79, 70, 229, 0.1); +} + +.room-item.selected { + background-color: rgba(79, 70, 229, 0.2); +} + +.room-name { + font-weight: 500; +} + +.room-description { + font-size: 0.875rem; + color: var(--secondary-color); +} + +.chat-content { + flex: 1; + display: flex; + flex-direction: column; +} + +.select-room-message { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + color: var(--secondary-color); + font-size: 1.125rem; +} + +.chat-room { + display: flex; + flex-direction: column; + height: 100%; +} + +.chat-header { + padding: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.chat-header h2 { + margin-bottom: 0.25rem; +} + +.chat-header p { + color: var(--secondary-color); + font-size: 0.875rem; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.message { + max-width: 70%; + padding: 0.75rem; + border-radius: 0.5rem; + background-color: #f3f4f6; + align-self: flex-start; +} + +.message.own-message { + background-color: rgba(79, 70, 229, 0.1); + align-self: flex-end; +} + +.message-header { + display: flex; + justify-content: space-between; + margin-bottom: 0.25rem; + font-size: 0.75rem; +} + +.username { + font-weight: 500; +} + +.time { + color: var(--secondary-color); +} + +.message-content { + word-break: break-word; +} + +.message-form { + display: flex; + padding: 1rem; + border-top: 1px solid var(--border-color); +} + +.message-form input { + flex: 1; + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: 0.25rem 0 0 0.25rem; + font-size: 1rem; +} + +.message-form button { + border-radius: 0 0.25rem 0.25rem 0; +} + +/* Create Room styles */ +.create-room { + padding: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.create-room-button { + width: 100%; +} + +.create-room-form { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); +} + +.create-room-form h3 { + margin-bottom: 1rem; +} + +@media (max-width: 768px) { + .chat-container { + flex-direction: column; + height: auto; + } + + .sidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--border-color); + } + + .chat-messages { + height: 50vh; + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..1f15f32 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,118 @@ +import { createSignal, Show } from 'solid-js'; +import { Provider } from '@urql/solid'; +import { client } from './lib/graphql-client'; +import { LoginForm } from './components/login-form'; +import { RegisterForm } from './components/register-form'; +import { RoomList } from './components/room-list'; +import { ChatRoom } from './components/chat-room'; +import { CreateRoom } from './components/create-room'; +import './App.css'; + +function App() { + const [isAuthenticated, setIsAuthenticated] = createSignal(false); + const [userId, setUserId] = createSignal(''); + const [selectedRoomId, setSelectedRoomId] = createSignal(null); + const [showRegister, setShowRegister] = createSignal(false); + + // Check if user is already authenticated + const checkAuth = () => { + const token = localStorage.getItem('token'); + const storedUserId = localStorage.getItem('userId'); + + if (token && storedUserId) { + setIsAuthenticated(true); + setUserId(storedUserId); + } + }; + + // Call checkAuth on component mount + checkAuth(); + + const handleLoginSuccess = (token: string, id: string) => { + setIsAuthenticated(true); + setUserId(id); + }; + + const handleLogout = () => { + localStorage.removeItem('token'); + localStorage.removeItem('userId'); + setIsAuthenticated(false); + setUserId(''); + setSelectedRoomId(null); + }; + + const handleSelectRoom = (roomId: string) => { + setSelectedRoomId(roomId); + }; + + return ( + +
+
+

Unreal Chat

+ {isAuthenticated() && ( + + )} +
+ +
+ +
+ + +
+ + } + > + + +
+ } + > +
+ + +
+ + Select a room to start chatting +
+ } + > + + +
+ + + + +
+ ); +} + +export default App; diff --git a/apps/web/src/assets/solid.svg b/apps/web/src/assets/solid.svg new file mode 100644 index 0000000..025aa30 --- /dev/null +++ b/apps/web/src/assets/solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/components/chat-room.tsx b/apps/web/src/components/chat-room.tsx new file mode 100644 index 0000000..190cdfb --- /dev/null +++ b/apps/web/src/components/chat-room.tsx @@ -0,0 +1,176 @@ +import { createSignal, createEffect, For, Show } from 'solid-js'; +import { gql } from '@urql/core'; +import { createQuery, createMutation, createSubscription } from '@urql/solid'; +import { Message, Room } from '../types'; + +const ROOM_QUERY = gql` + query GetRoom($id: ID!) { + room(id: $id) { + id + name + description + isPrivate + owner { + id + username + } + members { + id + username + } + } + } +`; + +const MESSAGES_QUERY = gql` + query GetMessages($roomId: ID!) { + messages(roomId: $roomId) { + id + content + createdAt + user { + id + username + } + } + } +`; + +const SEND_MESSAGE_MUTATION = gql` + mutation SendMessage($content: String!, $roomId: ID!) { + sendMessage(content: $content, roomId: $roomId) { + id + content + createdAt + user { + id + username + } + } + } +`; + +const MESSAGE_SUBSCRIPTION = gql` + subscription OnMessageAdded($roomId: ID!) { + messageAdded(roomId: $roomId) { + id + content + createdAt + user { + id + username + } + } + } +`; + +interface ChatRoomProps { + roomId: string; + userId: string; +} + +export function ChatRoom(props: ChatRoomProps) { + const [message, setMessage] = createSignal(''); + const [messages, setMessages] = createSignal([]); + + // Query room details + const [roomQuery] = createQuery({ + query: ROOM_QUERY, + variables: { id: props.roomId }, + }); + + // Query messages + const [messagesQuery] = createQuery({ + query: MESSAGES_QUERY, + variables: { roomId: props.roomId }, + }); + + // Send message mutation + const [, sendMessage] = createMutation(SEND_MESSAGE_MUTATION); + + // Subscribe to new messages + const [messageSubscription] = createSubscription({ + query: MESSAGE_SUBSCRIPTION, + variables: { roomId: props.roomId }, + }); + + // Load initial messages + createEffect(() => { + const result = messagesQuery; + if (result.data?.messages) { + setMessages(result.data.messages); + } + }); + + // Handle new messages from subscription + createEffect(() => { + const result = messageSubscription; + if (result.data?.messageAdded) { + const newMessage = result.data.messageAdded; + setMessages((prev) => [...prev, newMessage]); + } + }); + + const handleSendMessage = async (e: Event) => { + e.preventDefault(); + + if (!message().trim()) return; + + try { + await sendMessage({ + content: message(), + roomId: props.roomId, + }); + setMessage(''); + } catch (error) { + console.error('Failed to send message:', error); + } + }; + + const formatTime = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + return ( +
+ Loading room...
}> +
+

{roomQuery.data?.room.name}

+

{roomQuery.data?.room.description}

+
+ + +
+ Loading messages...
} + > + + {(message) => ( +
+
+ {message.user.username} + {formatTime(message.createdAt)} +
+
{message.content}
+
+ )} +
+ + + +
+ setMessage(e.currentTarget.value)} + placeholder='Type a message...' + /> + +
+ + ); +} diff --git a/apps/web/src/components/create-room.tsx b/apps/web/src/components/create-room.tsx new file mode 100644 index 0000000..71f646b --- /dev/null +++ b/apps/web/src/components/create-room.tsx @@ -0,0 +1,112 @@ +import { createSignal } from 'solid-js'; +import { gql } from '@urql/core'; +import { createMutation } from '@urql/solid'; + +const CREATE_ROOM_MUTATION = gql` + mutation CreateRoom( + $name: String! + $description: String + $isPrivate: Boolean + ) { + createRoom(name: $name, description: $description, isPrivate: $isPrivate) { + id + name + description + isPrivate + } + } +`; + +interface CreateRoomProps { + onRoomCreated: (roomId: string) => void; +} + +export function CreateRoom(props: CreateRoomProps) { + const [name, setName] = createSignal(''); + const [description, setDescription] = createSignal(''); + const [isPrivate, setIsPrivate] = createSignal(false); + const [error, setError] = createSignal(''); + const [isOpen, setIsOpen] = createSignal(false); + + const [state, executeMutation] = createMutation(CREATE_ROOM_MUTATION); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + + if (!name().trim()) { + setError('Room name is required'); + return; + } + + try { + const result = await executeMutation({ + name: name(), + description: description(), + isPrivate: isPrivate(), + }); + + if (result.error) { + setError(result.error.message); + return; + } + + if (result.data?.createRoom) { + setName(''); + setDescription(''); + setIsPrivate(false); + setIsOpen(false); + props.onRoomCreated(result.data.createRoom.id); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } + }; + + return ( +
+ + + {isOpen() && ( +
+

Create a New Room

+ {error() &&
{error()}
} +
+
+ + setName(e.currentTarget.value)} + required + /> +
+
+ +