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
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>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue