diff --git a/examples/next-js/src/app/globals.css b/examples/next-js/src/app/globals.css index e3734be15..24fa51ff2 100644 --- a/examples/next-js/src/app/globals.css +++ b/examples/next-js/src/app/globals.css @@ -1,42 +1,463 @@ -:root { - --background: #ffffff; - --foreground: #171717; +* { + margin: 0; + padding: 0; + box-sizing: border-box; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, sans-serif; + background: #000000; + height: 100vh; + overflow: hidden; + margin: 0; + padding: 0; } -html, -body { - max-width: 100vw; - overflow-x: hidden; +.app { + display: flex; + height: 100vh; + width: 100vw; + background: #000000; + overflow: hidden; } -body { - color: var(--foreground); - background: var(--background); - font-family: Arial, Helvetica, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; +/* Sidebar */ +.sidebar { + width: 280px; + background: #1c1c1e; + border-right: 1px solid #2c2c2e; + display: flex; + flex-direction: column; } -* { - box-sizing: border-box; - padding: 0; - margin: 0; +.sidebar-header { + padding: 20px 20px; + border-bottom: 1px solid #2c2c2e; + display: flex; + justify-content: space-between; + align-items: center; + height: 60px; + box-sizing: border-box; +} + +.logo { + display: flex; + align-items: center; + gap: 12px; +} + +.logo h1 { + font-size: 18px; + font-weight: 500; + color: #ffffff; +} + +.close-sidebar { + display: none; + background: none; + border: none; + color: #8e8e93; + cursor: pointer; + padding: 8px; + border-radius: 6px; + transition: all 0.2s ease; +} + +.close-sidebar:hover { + background: #3a3a3c; + color: #ffffff; +} + +.user-settings { + padding: 20px; + flex: 1; +} + +.setting-group { + margin-bottom: 20px; +} + +.setting-group label { + display: block; + font-size: 14px; + font-weight: 600; + color: #8e8e93; + margin-bottom: 8px; +} + +.setting-input { + width: 100%; + padding: 10px 14px; + border: 1px solid #3a3a3c; + border-radius: 8px; + font-size: 14px; + transition: all 0.2s ease; + background: #2c2c2e; + color: #ffffff; +} + +.setting-input:focus { + outline: none; + border-color: #007aff; + box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1); +} + +.setting-input::placeholder { + color: #8e8e93; +} + +.connection-status { + padding: 20px; + border-top: 1px solid #2c2c2e; + height: 60px; + box-sizing: border-box; + display: flex; + align-items: center; +} + +.status-indicator { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + transition: background-color 0.2s ease; +} + +.status-indicator.connected .status-dot { + background-color: #30d158; +} + +.status-indicator.disconnected .status-dot { + background-color: #ff3b30; +} + +.status-indicator.connected { + color: #30d158; +} + +.status-indicator.disconnected { + color: #ff3b30; +} + +/* Chat Container */ +.chat-container { + flex: 1; + display: flex; + flex-direction: column; + background: #000000; +} + +.chat-header { + padding: 12px 20px; + border-bottom: 1px solid #2c2c2e; + background: #1c1c1e; + display: flex; + align-items: center; + gap: 12px; +} + +.menu-button { + display: none; + background: none; + border: none; + color: #8e8e93; + cursor: pointer; + padding: 8px; + border-radius: 6px; + transition: all 0.2s ease; +} + +.menu-button:hover { + background: #3a3a3c; + color: #ffffff; +} + +.room-info h2 { + font-size: 16px; + font-weight: 600; + color: #ffffff; + margin-bottom: 2px; +} + +.room-info p { + font-size: 12px; + color: #8e8e93; +} + +.messages-container { + flex: 1; + overflow-y: auto; + background: #000000; +} + +.messages { + padding: 16px 20px; + min-height: 100%; +} + +.message-wrapper { + display: flex; + margin-bottom: 12px; + align-items: flex-end; +} + +.message-wrapper.own { + flex-direction: row-reverse; +} + +.message-avatar { + margin-right: 12px; + flex-shrink: 0; +} + +.message-wrapper.own .message-avatar { + margin-right: 0; + margin-left: 12px; +} + +.avatar { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: white; + text-transform: uppercase; +} + +.avatar-spacer { + width: 32px; + margin-right: 12px; +} + +.message-wrapper.own .avatar-spacer { + margin-right: 0; + margin-left: 12px; +} + +.message-content { + max-width: 70%; + display: flex; + flex-direction: column; +} + +.message-wrapper.own .message-content { + align-items: flex-end; +} + +.message-sender { + font-size: 12px; + font-weight: 600; + color: #64748b; + margin-bottom: 4px; + margin-left: 4px; +} + +.message-wrapper.own .message-sender { + margin-left: 0; + margin-right: 4px; +} + +.message-bubble { + padding: 10px 14px; + border-radius: 16px; + position: relative; + word-wrap: break-word; + max-width: 100%; +} + +.message-bubble.other { + background: #2c2c2e; + border: none; + border-bottom-left-radius: 4px; + color: #ffffff; +} + +.message-bubble.own { + background: #007aff; + color: white; + border-bottom-right-radius: 4px; +} + +.message-text { + font-size: 15px; + line-height: 1.3; + margin-bottom: 3px; +} + +.message-time { + font-size: 11px; + opacity: 0.5; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + color: #8e8e93; +} + +.empty-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.empty-state h3 { + font-size: 18px; + font-weight: 600; + color: #ffffff; + margin-bottom: 8px; +} + +.empty-state p { + font-size: 14px; +} + +.input-container { + padding: 12px 20px; + border-top: 1px solid #2c2c2e; + background: #1c1c1e; + height: 60px; + box-sizing: border-box; + display: flex; + align-items: center; +} + +.input-wrapper { + display: flex; + align-items: center; + gap: 8px; + background: #2c2c2e; + border: 1px solid #3a3a3c; + border-radius: 20px; + padding: 6px 6px 6px 16px; + transition: all 0.2s ease; + width: 100%; +} + +.input-wrapper:focus-within { + border-color: #007aff; + box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1); +} + +.message-input { + flex: 1; + border: none; + background: transparent; + font-size: 15px; + outline: none; + resize: none; + color: #ffffff; +} + +.message-input::placeholder { + color: #8e8e93; +} + +.send-button { + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: #007aff; + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.send-button:hover:not(:disabled) { + background: #0056cc; + transform: scale(1.05); +} + +.send-button:disabled { + background: #3a3a3c; + cursor: not-allowed; + transform: none; +} + +/* Scrollbar Styling */ +.messages-container::-webkit-scrollbar { + width: 6px; +} + +.messages-container::-webkit-scrollbar-track { + background: transparent; +} + +.messages-container::-webkit-scrollbar-thumb { + background: #3a3a3c; + border-radius: 3px; +} + +.messages-container::-webkit-scrollbar-thumb:hover { + background: #48484a; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .app { + height: 100vh; + } + + .sidebar { + width: 100%; + position: absolute; + z-index: 10; + transform: translateX(-100%); + transition: transform 0.3s ease; + } + + .sidebar.open { + transform: translateX(0); + } + + .close-sidebar { + display: block; + } + + .menu-button { + display: block; + } + + .chat-container { + width: 100%; + } + + .message-content { + max-width: 85%; + } } -a { - color: inherit; - text-decoration: none; +/* Animation for new messages */ +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } -@media (prefers-color-scheme: dark) { - html { - color-scheme: dark; - } +.message-wrapper { + animation: slideIn 0.3s ease; } diff --git a/examples/next-js/src/app/layout.tsx b/examples/next-js/src/app/layout.tsx index 742d4a97c..9e2c27453 100644 --- a/examples/next-js/src/app/layout.tsx +++ b/examples/next-js/src/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Rivet Chat", + description: "Real-time chat powered by RivetKit", }; export default function RootLayout({ diff --git a/examples/next-js/src/app/page.tsx b/examples/next-js/src/app/page.tsx index 330263604..8dd238dd6 100644 --- a/examples/next-js/src/app/page.tsx +++ b/examples/next-js/src/app/page.tsx @@ -1,12 +1,5 @@ -import { Counter } from "@/components/Counter"; -import styles from "./page.module.css"; +import { ChatRoom } from "@/components/ChatRoom"; export default function Home() { - return ( -
-
- -
-
- ); + return ; } diff --git a/examples/next-js/src/components/ChatRoom.tsx b/examples/next-js/src/components/ChatRoom.tsx new file mode 100644 index 000000000..3ff087626 --- /dev/null +++ b/examples/next-js/src/components/ChatRoom.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { createClient, createRivetKit } from "@rivetkit/react"; +import { useEffect, useState, useRef } from "react"; +import type { Message, registry } from "../rivet/registry"; + +export const { useActor } = createRivetKit({ + endpoint: process.env.NEXT_PUBLIC_RIVET_ENDPOINT ?? "http://localhost:3000/api/rivet", + namespace: process.env.NEXT_PUBLIC_RIVET_NAMESPACE, + token: process.env.NEXT_PUBLIC_RIVET_TOKEN, +}); + +// Generate avatar color based on username +const getAvatarColor = (username: string) => { + const colors = [ + "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", + "#DDA0DD", "#98D8C8", "#F7DC6F", "#BB8FCE", "#85C1E9" + ]; + const index = username.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + return colors[index % colors.length]; +}; + +// Generate initials from username +const getInitials = (username: string) => { + return username.split(' ').map(name => name[0]).join('').toUpperCase().slice(0, 2); +}; + +export function ChatRoom() { + const [roomId, setRoomId] = useState("general"); + const [username, setUsername] = useState("User"); + const [input, setInput] = useState(""); + const [messages, setMessages] = useState([]); + const [isConnected, setIsConnected] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(false); + const messagesEndRef = useRef(null); + + const chatRoom = useActor({ + name: "chatRoom", + key: [roomId], + }); + + useEffect(() => { + if (chatRoom.connection) { + setIsConnected(true); + chatRoom.connection.getHistory().then(setMessages); + } else { + setIsConnected(false); + } + }, [chatRoom.connection]); + + chatRoom.useEvent("newMessage", (message: Message) => { + setMessages((prev) => [...prev, message]); + }); + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const sendMessage = async () => { + if (chatRoom.connection && input.trim()) { + await chatRoom.connection.sendMessage(username, input); + setInput(""); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }; + + const formatTime = (timestamp: number) => { + const date = new Date(timestamp); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + + if (isToday) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } else { + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + }; + + return ( +
+
+
+
+

Rivet Chat

+
+ +
+ +
+
+ + setUsername(e.target.value)} + placeholder="Enter your username" + className="setting-input" + /> +
+
+ + setRoomId(e.target.value)} + placeholder="Enter room name" + className="setting-input" + /> +
+
+ +
+
+
+ {isConnected ? 'Connected' : 'Disconnected'} +
+
+
+ +
+
+ +
+

#{roomId}

+

{messages.length} messages

+
+
+ +
+ {messages.length === 0 ? ( +
+
💭
+

Welcome to #{roomId}

+

Start the conversation by sending a message below.

+
+ ) : ( +
+ {messages.map((msg, i) => { + const isCurrentUser = msg.sender === username; + const prevMessage = i > 0 ? messages[i - 1] : null; + const showAvatar = !prevMessage || prevMessage.sender !== msg.sender; + + return ( +
+ {!isCurrentUser && showAvatar && ( +
+
+ {getInitials(msg.sender)} +
+
+ )} + {!isCurrentUser && !showAvatar &&
} + +
+ {!isCurrentUser && showAvatar && ( +
{msg.sender}
+ )} +
+
{msg.text}
+
+ {formatTime(msg.timestamp)} +
+
+
+
+ ); + })} +
+
+ )} +
+ +
+
+ setInput(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Type a message..." + disabled={!isConnected} + className="message-input" + /> + +
+
+
+
+ ); +} diff --git a/examples/next-js/src/components/Counter.module.css b/examples/next-js/src/components/Counter.module.css deleted file mode 100644 index 5cd96317d..000000000 --- a/examples/next-js/src/components/Counter.module.css +++ /dev/null @@ -1,67 +0,0 @@ -.field { - display: flex; - margin: 16px 0; - flex-direction: column; - gap: 8px; -} - -.field label { - font-weight: bold; -} - -.field input { - padding: 8px; - border: 1px solid var(--gray-alpha-200); - border-radius: 16px; - font-size: 16px; -} - -.field input:focus { - border-color: var(--gray-alpha-100); - outline: none; -} -.field input::placeholder { - color: var(--gray-alpha-200); -} - -.button { - appearance: none; - border-radius: 128px; - height: 48px; - padding: 0 20px; - 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: var(--foreground); - color: var(--background); - gap: 8px; -} - -/* Enable hover only on non-touch devices */ -@media (hover: hover) and (pointer: fine) { - .button:hover { - background: var(--button-primary-hover); - border-color: transparent; - } -} - -.counterValue { - font-variant-numeric: tabular-nums; -} - -.counter { - display: flex; - align-items: center; - font-size: 24px; - font-weight: bold; - margin-bottom: 16px; -} \ No newline at end of file diff --git a/examples/next-js/src/components/Counter.tsx b/examples/next-js/src/components/Counter.tsx deleted file mode 100644 index 9205457e8..000000000 --- a/examples/next-js/src/components/Counter.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { createRivetKit } from "@rivetkit/next-js/client"; -import type { registry } from "@/rivet/registry"; -import { useState } from "react"; -import styles from "./Counter.module.css"; - -export const { useActor } = createRivetKit({ - endpoint: process.env.NEXT_PUBLIC_RIVET_ENDPOINT ?? "http://localhost:3000/api/rivet", - namespace: process.env.NEXT_PUBLIC_RIVET_NAMESPACE, - token: process.env.NEXT_PUBLIC_RIVET_TOKEN, -}); - -export function Counter() { - const [count, setCount] = useState(0); - const [counterName, setCounterName] = useState("test-counter"); - - const counter = useActor({ - name: "counter", - key: [counterName], - }); - - counter.useEvent("newCount", (x: number) => setCount(x)); - - const increment = async () => { - await counter.connection?.increment(1); - }; - - return ( -
-
- - setCounterName(e.target.value)} - placeholder="Counter name" - /> -
- -
-

- Counter: {count} -

-
- -
- ); -} diff --git a/examples/next-js/src/rivet/registry.ts b/examples/next-js/src/rivet/registry.ts index 4afe732a3..87e7709a7 100644 --- a/examples/next-js/src/rivet/registry.ts +++ b/examples/next-js/src/rivet/registry.ts @@ -1,16 +1,29 @@ import { actor, setup } from "rivetkit"; -export const counter = actor({ - state: { count: 0 }, +export type Message = { sender: string; text: string; timestamp: number }; + +export const chatRoom = actor({ + // Persistent state that survives restarts + state: { + messages: [] as Message[], + }, + actions: { - increment: (c, x: number) => { - c.state.count += x; - c.broadcast("newCount", c.state.count); - return c.state.count; + // Callable functions from clients + sendMessage: (c, sender: string, text: string) => { + const message = { sender, text, timestamp: Date.now() }; + // State changes are automatically persisted + c.state.messages.push(message); + // Send events to all connected clients + c.broadcast("newMessage", message); + return message; }, + + getHistory: (c) => c.state.messages, }, }); +// Register actors for use export const registry = setup({ - use: { counter }, + use: { chatRoom }, });