refactor: migrate from Next.js to SolidJS and GraphQL
- Converted web application from Next.js to SolidJS with Vite - Replaced React components with SolidJS components - Implemented GraphQL client using URQL - Added authentication, room, and chat components - Updated project structure and configuration files - Removed unnecessary Next.js and docs-related files - Added Docker support for web and API applications
This commit is contained in:
parent
8f3aa2fc26
commit
16731409df
81 changed files with 13585 additions and 1163 deletions
367
apps/web/src/App.css
Normal file
367
apps/web/src/App.css
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
118
apps/web/src/App.tsx
Normal file
118
apps/web/src/App.tsx
Normal file
|
@ -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<string | null>(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 (
|
||||
<Provider value={client}>
|
||||
<div class='app'>
|
||||
<header class='app-header'>
|
||||
<h1>Unreal Chat</h1>
|
||||
{isAuthenticated() && (
|
||||
<button class='logout-button' onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main class='app-main'>
|
||||
<Show
|
||||
when={isAuthenticated()}
|
||||
fallback={
|
||||
<div class='auth-container'>
|
||||
<div class='auth-tabs'>
|
||||
<button
|
||||
class={!showRegister() ? 'active' : ''}
|
||||
onClick={() => setShowRegister(false)}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
class={showRegister() ? 'active' : ''}
|
||||
onClick={() => setShowRegister(true)}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={showRegister()}
|
||||
fallback={<LoginForm onLoginSuccess={handleLoginSuccess} />}
|
||||
>
|
||||
<RegisterForm onRegisterSuccess={handleLoginSuccess} />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class='chat-container'>
|
||||
<aside class='sidebar'>
|
||||
<CreateRoom onRoomCreated={handleSelectRoom} />
|
||||
<RoomList
|
||||
onSelectRoom={handleSelectRoom}
|
||||
selectedRoomId={selectedRoomId() || undefined}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div class='chat-content'>
|
||||
<Show
|
||||
when={selectedRoomId()}
|
||||
fallback={
|
||||
<div class='select-room-message'>
|
||||
Select a room to start chatting
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ChatRoom roomId={selectedRoomId()!} userId={userId()} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
1
apps/web/src/assets/solid.svg
Normal file
1
apps/web/src/assets/solid.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>
|
After Width: | Height: | Size: 1.6 KiB |
176
apps/web/src/components/chat-room.tsx
Normal file
176
apps/web/src/components/chat-room.tsx
Normal file
|
@ -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<Message[]>([]);
|
||||
|
||||
// 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 (
|
||||
<div class='chat-room'>
|
||||
<Show when={roomQuery.data?.room} fallback={<div>Loading room...</div>}>
|
||||
<div class='chat-header'>
|
||||
<h2>{roomQuery.data?.room.name}</h2>
|
||||
<p>{roomQuery.data?.room.description}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class='chat-messages'>
|
||||
<Show
|
||||
when={!roomQuery.fetching}
|
||||
fallback={<div>Loading messages...</div>}
|
||||
>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<div
|
||||
class={`message ${message.user.id === props.userId ? 'own-message' : ''}`}
|
||||
>
|
||||
<div class='message-header'>
|
||||
<span class='username'>{message.user.username}</span>
|
||||
<span class='time'>{formatTime(message.createdAt)}</span>
|
||||
</div>
|
||||
<div class='message-content'>{message.content}</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<form class='message-form' onSubmit={handleSendMessage}>
|
||||
<input
|
||||
type='text'
|
||||
value={message()}
|
||||
onInput={(e) => setMessage(e.currentTarget.value)}
|
||||
placeholder='Type a message...'
|
||||
/>
|
||||
<button type='submit'>Send</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
112
apps/web/src/components/create-room.tsx
Normal file
112
apps/web/src/components/create-room.tsx
Normal file
|
@ -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 (
|
||||
<div class='create-room'>
|
||||
<button class='create-room-button' onClick={() => setIsOpen(!isOpen())}>
|
||||
{isOpen() ? 'Cancel' : 'Create Room'}
|
||||
</button>
|
||||
|
||||
{isOpen() && (
|
||||
<div class='create-room-form'>
|
||||
<h3>Create a New Room</h3>
|
||||
{error() && <div class='error'>{error()}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div class='form-group'>
|
||||
<label for='room-name'>Room Name</label>
|
||||
<input
|
||||
type='text'
|
||||
id='room-name'
|
||||
value={name()}
|
||||
onInput={(e) => setName(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class='form-group'>
|
||||
<label for='room-description'>Description (optional)</label>
|
||||
<textarea
|
||||
id='room-description'
|
||||
value={description()}
|
||||
onInput={(e) => setDescription(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class='form-group checkbox'>
|
||||
<label>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={isPrivate()}
|
||||
onChange={(e) => setIsPrivate(e.currentTarget.checked)}
|
||||
/>
|
||||
Private Room
|
||||
</label>
|
||||
</div>
|
||||
<button type='submit' disabled={state.fetching}>
|
||||
{state.fetching ? 'Creating...' : 'Create Room'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
89
apps/web/src/components/login-form.tsx
Normal file
89
apps/web/src/components/login-form.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
import { createSignal } from 'solid-js';
|
||||
import { gql } from '@urql/core';
|
||||
import { createMutation } from '@urql/solid';
|
||||
|
||||
const LOGIN_MUTATION = gql`
|
||||
mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
token
|
||||
user {
|
||||
id
|
||||
username
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface LoginFormProps {
|
||||
onLoginSuccess: (token: string, userId: string) => void;
|
||||
}
|
||||
|
||||
export function LoginForm(props: LoginFormProps) {
|
||||
const [email, setEmail] = createSignal('');
|
||||
const [password, setPassword] = createSignal('');
|
||||
const [error, setError] = createSignal('');
|
||||
|
||||
const [loginState, login] = createMutation(LOGIN_MUTATION);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email() || !password()) {
|
||||
setError('Please enter both email and password');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await login({ email: email(), password: password() });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.login) {
|
||||
const { token, user } = result.data.login;
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('userId', user.id);
|
||||
props.onLoginSuccess(token, user.id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class='login-form'>
|
||||
<h2>Login</h2>
|
||||
{error() && <div class='error'>{error()}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div class='form-group'>
|
||||
<label for='email'>Email</label>
|
||||
<input
|
||||
type='email'
|
||||
id='email'
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class='form-group'>
|
||||
<label for='password'>Password</label>
|
||||
<input
|
||||
type='password'
|
||||
id='password'
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button type='submit' disabled={loginState.fetching}>
|
||||
{loginState.fetching ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
118
apps/web/src/components/register-form.tsx
Normal file
118
apps/web/src/components/register-form.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
import { createSignal } from 'solid-js';
|
||||
import { gql } from '@urql/core';
|
||||
import { createMutation } from '@urql/solid';
|
||||
|
||||
const REGISTER_MUTATION = gql`
|
||||
mutation Register($email: String!, $username: String!, $password: String!) {
|
||||
register(email: $email, username: $username, password: $password) {
|
||||
token
|
||||
user {
|
||||
id
|
||||
username
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface RegisterFormProps {
|
||||
onRegisterSuccess: (token: string, userId: string) => void;
|
||||
}
|
||||
|
||||
export function RegisterForm(props: RegisterFormProps) {
|
||||
const [email, setEmail] = createSignal('');
|
||||
const [username, setUsername] = createSignal('');
|
||||
const [password, setPassword] = createSignal('');
|
||||
const [confirmPassword, setConfirmPassword] = createSignal('');
|
||||
const [error, setError] = createSignal('');
|
||||
|
||||
const [state, executeMutation] = createMutation(REGISTER_MUTATION);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email() || !username() || !password() || !confirmPassword()) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password() !== confirmPassword()) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeMutation({
|
||||
email: email(),
|
||||
username: username(),
|
||||
password: password(),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.register) {
|
||||
const { token, user } = result.data.register;
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('userId', user.id);
|
||||
props.onRegisterSuccess(token, user.id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class='register-form'>
|
||||
<h2>Register</h2>
|
||||
{error() && <div class='error'>{error()}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div class='form-group'>
|
||||
<label for='email'>Email</label>
|
||||
<input
|
||||
type='email'
|
||||
id='email'
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class='form-group'>
|
||||
<label for='username'>Username</label>
|
||||
<input
|
||||
type='text'
|
||||
id='username'
|
||||
value={username()}
|
||||
onInput={(e) => setUsername(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class='form-group'>
|
||||
<label for='password'>Password</label>
|
||||
<input
|
||||
type='password'
|
||||
id='password'
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class='form-group'>
|
||||
<label for='confirm-password'>Confirm Password</label>
|
||||
<input
|
||||
type='password'
|
||||
id='confirm-password'
|
||||
value={confirmPassword()}
|
||||
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type='submit' disabled={state.fetching}>
|
||||
{state.fetching ? 'Registering...' : 'Register'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
89
apps/web/src/components/room-list.tsx
Normal file
89
apps/web/src/components/room-list.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
import { createSignal, createEffect, For, Show } from 'solid-js';
|
||||
import { gql } from '@urql/core';
|
||||
import { createQuery, createSubscription } from '@urql/solid';
|
||||
import { Room } from '../types';
|
||||
|
||||
const ROOMS_QUERY = gql`
|
||||
query GetRooms {
|
||||
rooms {
|
||||
id
|
||||
name
|
||||
description
|
||||
isPrivate
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ROOM_ADDED_SUBSCRIPTION = gql`
|
||||
subscription OnRoomAdded {
|
||||
roomAdded {
|
||||
id
|
||||
name
|
||||
description
|
||||
isPrivate
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface RoomListProps {
|
||||
onSelectRoom: (roomId: string) => void;
|
||||
selectedRoomId?: string;
|
||||
}
|
||||
|
||||
export function RoomList(props: RoomListProps) {
|
||||
const [rooms, setRooms] = createSignal<Room[]>([]);
|
||||
|
||||
// Query rooms
|
||||
const [roomsQuery] = createQuery({
|
||||
query: ROOMS_QUERY,
|
||||
});
|
||||
|
||||
// Subscribe to new rooms
|
||||
const [roomAddedSubscription] = createSubscription({
|
||||
query: ROOM_ADDED_SUBSCRIPTION,
|
||||
});
|
||||
|
||||
// Load initial rooms
|
||||
createEffect(() => {
|
||||
const result = roomsQuery;
|
||||
if (result.data?.rooms) {
|
||||
setRooms(result.data.rooms);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle new rooms from subscription
|
||||
createEffect(() => {
|
||||
const result = roomAddedSubscription;
|
||||
if (result.data?.roomAdded) {
|
||||
const newRoom = result.data.roomAdded;
|
||||
setRooms((prev) => {
|
||||
// Check if room already exists
|
||||
if (prev.some((room) => room.id === newRoom.id)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, newRoom];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class='room-list'>
|
||||
<h2>Chat Rooms</h2>
|
||||
<Show when={!roomsQuery.fetching} fallback={<div>Loading rooms...</div>}>
|
||||
<For each={rooms()}>
|
||||
{(room) => (
|
||||
<div
|
||||
class={`room-item ${props.selectedRoomId === room.id ? 'selected' : ''}`}
|
||||
onClick={() => props.onSelectRoom(room.id)}
|
||||
>
|
||||
<div class='room-name'>{room.name}</div>
|
||||
<div class='room-description'>{room.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
68
apps/web/src/index.css
Normal file
68
apps/web/src/index.css
Normal file
|
@ -0,0 +1,68 @@
|
|||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
8
apps/web/src/index.tsx
Normal file
8
apps/web/src/index.tsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
/* @refresh reload */
|
||||
import { render } from 'solid-js/web'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
render(() => <App />, root!)
|
50
apps/web/src/lib/graphql-client.ts
Normal file
50
apps/web/src/lib/graphql-client.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { createClient, fetchExchange, subscriptionExchange } from '@urql/core';
|
||||
import { createClient as createWSClient } from 'graphql-ws';
|
||||
|
||||
// Get API URLs from environment variables
|
||||
const API_URL =
|
||||
import.meta.env.VITE_API_URL || 'https://chat-api.jusemon.com/graphql';
|
||||
const WS_URL =
|
||||
import.meta.env.VITE_WS_URL || 'wss://chat-api.jusemon.com/graphql';
|
||||
console.log('Current API_URL', API_URL);
|
||||
console.log('Current WS_URL', WS_URL);
|
||||
|
||||
// Create a WebSocket client for GraphQL subscriptions
|
||||
const wsClient = createWSClient({
|
||||
url: WS_URL,
|
||||
});
|
||||
|
||||
// Create the URQL client
|
||||
export const client = createClient({
|
||||
url: API_URL,
|
||||
exchanges: [
|
||||
fetchExchange,
|
||||
subscriptionExchange({
|
||||
forwardSubscription: (operation) => ({
|
||||
subscribe: (sink) => {
|
||||
const dispose = wsClient.subscribe(
|
||||
{
|
||||
...operation,
|
||||
query: operation.query || '',
|
||||
},
|
||||
sink as any
|
||||
);
|
||||
return {
|
||||
unsubscribe: dispose,
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
// For development, we'll add a simple header-based authentication
|
||||
fetchOptions: () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const userId = localStorage.getItem('userId');
|
||||
return {
|
||||
headers: {
|
||||
'user-id': userId || '',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
33
apps/web/src/types/index.ts
Normal file
33
apps/web/src/types/index.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isPrivate: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
owner: User;
|
||||
members: User[];
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: User;
|
||||
room: Room;
|
||||
}
|
||||
|
||||
export interface AuthPayload {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
1
apps/web/src/vite-env.d.ts
vendored
Normal file
1
apps/web/src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
Loading…
Add table
Add a link
Reference in a new issue