7 コミット 9f796f6547 ... 2977b1ef17

作者 SHA1 メッセージ 日付
  Lee Morgan 2977b1ef17 Scroll now follows the text 3 週間 前
  Lee Morgan 1f038d104b Enlargen game area 3 週間 前
  Lee Morgan 76a1e5e497 Add in proper streaming to text. 3 週間 前
  Lee Morgan 90baed0ce7 Add style to the game page 3 週間 前
  Lee Morgan e396150be6 Add displaying of messages, with little to no style 3 週間 前
  Lee Morgan 7030f205f7 Add back button on character creation page 3 週間 前
  Lee Morgan e89365309e Finish game creation 3 週間 前

+ 11 - 1
package-lock.json

@@ -10,7 +10,8 @@
 			"dependencies": {
 				"@stripe/stripe-js": "^9.8.0",
 				"dotenv": "^17.4.2",
-				"express": "^5.2.1"
+				"express": "^5.2.1",
+				"undici": "^8.5.0"
 			},
 			"devDependencies": {
 				"@sveltejs/adapter-auto": "^7.0.1",
@@ -2050,6 +2051,15 @@
 				"url": "https://opencollective.com/express"
 			}
 		},
+		"node_modules/undici": {
+			"version": "8.5.0",
+			"resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz",
+			"integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=22.19.0"
+			}
+		},
 		"node_modules/unpipe": {
 			"version": "1.0.0",
 			"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

+ 2 - 1
package.json

@@ -19,6 +19,7 @@
 	"dependencies": {
 		"@stripe/stripe-js": "^9.8.0",
 		"dotenv": "^17.4.2",
-		"express": "^5.2.1"
+		"express": "^5.2.1",
+		"undici": "^8.5.0"
 	}
 }

+ 352 - 3
src/routes/(app)/game/[game_id]/+page.svelte

@@ -1,8 +1,357 @@
 <script>
     import {page} from "$app/state";
-    console.log(page);
+    import {PUBLIC_API_URL} from "$env/static/public";
+    import {onMount} from "svelte";
+
+    import Sidebar from "./Sidebar.svelte";
+    import Message from "./Message.svelte";
+
+    let game = $state(page.data.data.game);
+    let character = $state(page.data.data.character);
+    let sessions = $state(page.data.data.sessions.sort((a, b) => a.started_at > b.started_at ? 1 : -1));
+    let turns = $state(page.data.data.turns);
+    let tokensRemaining = $state(page.data.data.tokens_remaining);
+    let streaming = $state(false);
+
+    let inputText = $state("");
+    let sidebarOpen = $state(false);
+    let messagesEl;
+
+    const scrollToBottom = () => {
+        if (messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
+    };
+
+    $effect(() => {
+        turns.length;
+        if (turns.length > 0) turns[turns.length - 1].llm_text;
+        scrollToBottom();
+    });
+
+    const autogrow = (node) => {
+        const update = () => {
+            node.style.height = "auto";
+            node.style.height = Math.min(node.scrollHeight, 200) + "px";
+        };
+        node.addEventListener("input", update);
+        return { destroy: () => node.removeEventListener("input", update) };
+    };
+
+    const handleSubmit = (e) => {
+        e.preventDefault();
+        const text = inputText.trim();
+        if (!text || streaming) return;
+        inputText = "";
+        sendMessage(text);
+    };
+
+    const sendMessage = async (text)=>{
+        let newTurn = {user_text: text};
+        turns.push(newTurn);
+
+        const response = await fetch(`${PUBLIC_API_URL}/game/${game.id}/session/${sessions[0].id}/turn`, {
+            method: "POST",
+            headers: {
+                "Content-Type": "application/json",
+                "Accept-Encoding": "identity"
+            },
+            credentials: "include",
+            body: JSON.stringify({content: text})
+        });
+
+        const reader = response.body.getReader();
+        const decoder = new TextDecoder();
+        let buffer = "";
+
+        while(true){
+            const {done, value} = await reader.read();
+            if(done) break;
+
+            buffer += decoder.decode(value, {stream: true});
+            const lines = buffer.split("\n");
+            buffer = lines.pop();
+
+            for(let i = 0; i < lines.length; i++){
+                if(!lines[i].startsWith("data:")) continue;
+                const data = lines[i].slice(5).trim();
+                if(data === "[DONE]") continue;
+                try{
+                    const json = JSON.parse(data);
+                    const chunk = json.choices?.[0]?.delta?.content;
+                    if(chunk) turns[turns.length-1].llm_text += chunk;
+                }catch{}
+            }
+        }
+
+        streaming = false;
+    }
+
+    onMount(()=>{
+        if(turns.length === 0){
+            sendMessage("");
+        }
+    });
 </script>
 
-<main>
+<div class="page">
+
+{#if sidebarOpen}
+    <div class="sidebar-overlay" onclick={() => sidebarOpen = false}></div>
+{/if}
+
+<Sidebar
+    {game}
+    {character}
+    {tokensRemaining}
+    open={sidebarOpen}
+    onClose={() => sidebarOpen = false}
+/>
+
+<div class="main">
+    <div class="mobile-bar">
+        <button class="menu-btn" onclick={() => sidebarOpen = true} aria-label="Open menu">
+            <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
+                <line x1="3" y1="6" x2="21" y2="6"/>
+                <line x1="3" y1="12" x2="21" y2="12"/>
+                <line x1="3" y1="18" x2="21" y2="18"/>
+            </svg>
+        </button>
+        <span class="mobile-title">{game.title}</span>
+    </div>
+
+    <main class="messages" bind:this={messagesEl}>
+        <div class="messages-inner">
+            {#each turns as turn, i}
+                {#if i !== 0}
+                    <Message {turn} role="user" />
+                {/if}
+                <Message
+                    {turn}
+                    role="llm"
+                    streaming={streaming && i === turns.length - 1}
+                />
+            {/each}
+
+            {#if streaming}
+                <div class="typing-indicator">
+                    <span></span><span></span><span></span>
+                </div>
+            {/if}
+        </div>
+    </main>
+
+    <div class="composer-wrap">
+        <form class="composer" onsubmit={handleSubmit}>
+            <textarea
+                bind:value={inputText}
+                use:autogrow
+                placeholder="What do you do?"
+                rows="1"
+                disabled={streaming}
+                onkeydown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(e); } }}
+            ></textarea>
+            <button type="submit" disabled={streaming || !inputText.trim()} aria-label="Send message">
+                <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">
+                    <path d="M12 19V5M5 12l7-7 7 7"/>
+                </svg>
+            </button>
+        </form>
+        <p class="composer-hint">Enter to send &nbsp;·&nbsp; Shift+Enter for new line</p>
+    </div>
+</div>
+
+</div>
+
+<style>
+    .page {
+        display: flex;
+        height: 100vh;
+        overflow: hidden;
+        background: #0b0b10;
+    }
+
+    .sidebar-overlay {
+        display: none;
+    }
+
+    .main {
+        flex: 1;
+        display: flex;
+        flex-direction: column;
+        height: 100vh;
+        background: #0b0b10;
+        overflow: hidden;
+        min-width: 0;
+    }
+
+    /* ── Mobile bar ── */
+    .mobile-bar {
+        display: none;
+        align-items: center;
+        gap: 0.75rem;
+        padding: 0.75rem 1rem;
+        border-bottom: 1px solid #1a1a26;
+        background: #0d0d16;
+        flex-shrink: 0;
+    }
+
+    .menu-btn {
+        background: none;
+        border: none;
+        color: #6b6b7a;
+        cursor: pointer;
+        padding: 4px;
+        border-radius: 6px;
+        display: flex;
+        align-items: center;
+        transition: color 0.15s;
+    }
+
+    .menu-btn:hover { color: #e5e5ea; }
+
+    .mobile-title {
+        font-size: 0.95rem;
+        font-weight: 700;
+        color: #e5e5ea;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+    }
+
+    /* ── Messages ── */
+    .messages {
+        flex: 1;
+        overflow-y: auto;
+        scroll-behavior: smooth;
+    }
+
+    .messages::-webkit-scrollbar { width: 5px; }
+    .messages::-webkit-scrollbar-track { background: transparent; }
+    .messages::-webkit-scrollbar-thumb { background: #1e1e2e; border-radius: 3px; }
+
+    .messages-inner {
+        max-width: 800px;
+        margin: 0 auto;
+        padding: 2rem 2rem 1rem;
+        display: flex;
+        flex-direction: column;
+        gap: 1rem;
+    }
+
+    /* ── Typing indicator ── */
+    .typing-indicator {
+        display: flex;
+        align-items: center;
+        gap: 5px;
+        padding: 0.75rem 1rem;
+        background: #1a1a28;
+        border: 1px solid #2a2a3a;
+        border-radius: 16px;
+        border-bottom-left-radius: 4px;
+        width: fit-content;
+    }
+
+    .typing-indicator span {
+        width: 7px;
+        height: 7px;
+        border-radius: 50%;
+        background: #44445a;
+        animation: bounce 1.2s ease-in-out infinite;
+    }
+
+    .typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
+    .typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
+
+    @keyframes bounce {
+        0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
+        30% { transform: translateY(-5px); opacity: 1; }
+    }
+
+    /* ── Composer ── */
+    .composer-wrap {
+        flex-shrink: 0;
+        border-top: 1px solid #1a1a26;
+        background: #0d0d16;
+        padding: 1rem 2rem 0.6rem;
+    }
+
+    .composer {
+        max-width: 800px;
+        margin: 0 auto;
+        display: flex;
+        align-items: flex-end;
+        gap: 0.75rem;
+        background: #13131e;
+        border: 1px solid #2a2a3a;
+        border-radius: 14px;
+        padding: 0.6rem 0.6rem 0.6rem 1rem;
+        transition: border-color 0.2s, box-shadow 0.2s;
+    }
+
+    .composer:focus-within {
+        border-color: #5a5aff;
+        box-shadow: 0 0 0 3px rgba(90, 90, 255, 0.1);
+    }
+
+    .composer textarea {
+        flex: 1;
+        background: none;
+        border: none;
+        outline: none;
+        resize: none;
+        font-size: 0.95rem;
+        line-height: 1.6;
+        color: #e5e5ea;
+        font-family: inherit;
+        min-height: 26px;
+        max-height: 200px;
+        overflow-y: auto;
+        padding: 0;
+    }
+
+    .composer textarea::placeholder { color: #44445a; font-style: italic; }
+    .composer textarea:disabled { opacity: 0.5; cursor: not-allowed; }
+
+    .composer button {
+        width: 36px;
+        height: 36px;
+        border-radius: 9px;
+        border: none;
+        background: #5a5aff;
+        color: #fff;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        cursor: pointer;
+        flex-shrink: 0;
+        transition: background 0.15s, transform 0.1s, opacity 0.15s;
+    }
+
+    .composer button:hover:not(:disabled) { background: #4646e0; }
+    .composer button:active:not(:disabled) { transform: scale(0.93); }
+    .composer button:disabled { opacity: 0.35; cursor: not-allowed; }
+
+    .composer-hint {
+        max-width: 800px;
+        margin: 0.4rem auto 0;
+        font-size: 0.7rem;
+        color: #2e2e44;
+        text-align: right;
+        padding: 0 0.25rem;
+    }
+
+    /* ── Mobile ── */
+    @media (max-width: 768px) {
+        .sidebar-overlay {
+            display: block;
+            position: fixed;
+            inset: 0;
+            background: rgba(0, 0, 0, 0.6);
+            z-index: 199;
+        }
 
-</main>
+        .mobile-bar { display: flex; }
+        .messages-inner { padding: 1.25rem 1rem 0.75rem; }
+        .composer-wrap { padding: 0.75rem 1rem 0.5rem; }
+        .composer-hint { display: none; }
+    }
+</style>

+ 117 - 0
src/routes/(app)/game/[game_id]/Message.svelte

@@ -0,0 +1,117 @@
+<script>
+    let { turn, role, streaming = false } = $props();
+
+    const isUser = role === "user";
+    const content = $derived(isUser ? turn.user_text : turn.llm_text);
+
+    function formatDateTime(dateStr) {
+        return new Date(dateStr).toLocaleString("en-US", {
+            month: "short",
+            day: "numeric",
+            hour: "numeric",
+            minute: "2-digit",
+            hour12: true
+        });
+    }
+</script>
+
+<div class="message" class:user={isUser} class:assistant={!isUser}>
+    <div class="bubble">
+        <p class="text">
+            {content}{#if streaming}<span class="cursor"></span>{/if}
+        </p>
+        {#if !streaming}
+            <div class="meta">
+                <span class="time">{formatDateTime(turn.created_at)}</span>
+                {#if !isUser}
+                    <span class="dot">·</span>
+                    <span class="tokens">{turn.tokens_used} tokens</span>
+                {/if}
+            </div>
+        {/if}
+    </div>
+</div>
+
+<style>
+    .message {
+        display: flex;
+        max-width: 100%;
+    }
+
+    .message.user {
+        justify-content: flex-end;
+    }
+
+    .message.assistant {
+        justify-content: flex-start;
+    }
+
+    .bubble {
+        max-width: 72%;
+        display: flex;
+        flex-direction: column;
+        gap: 0.4rem;
+    }
+
+    .message.user .bubble {
+        align-items: flex-end;
+    }
+
+    .message.assistant .bubble {
+        align-items: flex-start;
+    }
+
+    .text {
+        padding: 0.75rem 1rem;
+        border-radius: 16px;
+        font-size: 0.95rem;
+        line-height: 1.6;
+        color: #e5e5ea;
+        white-space: pre-wrap;
+        word-break: break-word;
+    }
+
+    .message.user .text {
+        background: #5a5aff;
+        border-bottom-right-radius: 4px;
+    }
+
+    .message.assistant .text {
+        background: #1a1a28;
+        border: 1px solid #2a2a3a;
+        border-bottom-left-radius: 4px;
+    }
+
+    .meta {
+        display: flex;
+        align-items: center;
+        gap: 0.35rem;
+        font-size: 0.72rem;
+        color: #44445a;
+        padding: 0 0.25rem;
+    }
+
+    .dot {
+        color: #2a2a3a;
+    }
+
+    .cursor {
+        display: inline-block;
+        width: 2px;
+        height: 1em;
+        background: #a855f7;
+        margin-left: 2px;
+        vertical-align: text-bottom;
+        border-radius: 1px;
+        animation: blink 0.9s ease-in-out infinite;
+    }
+
+    @keyframes blink {
+        0%, 100% { opacity: 1; }
+        50% { opacity: 0; }
+    }
+
+    @media (max-width: 600px) {
+        .bubble { max-width: 88%; }
+    }
+</style>

+ 249 - 0
src/routes/(app)/game/[game_id]/Sidebar.svelte

@@ -0,0 +1,249 @@
+<script>
+    import favicon from "$lib/assets/favicon.png";
+    import { PUBLIC_API_URL } from "$env/static/public";
+    import { goto } from "$app/navigation";
+
+    let { game, character, tokensRemaining, open = false, onClose } = $props();
+
+    const logout = async () => {
+        await fetch(`${PUBLIC_API_URL}/user/logout`, { credentials: "include" });
+        goto("/");
+    };
+</script>
+
+<aside class="sidebar" class:open>
+    <div class="sidebar-header">
+        <a href="/dashboard" class="brand">
+            <img src={favicon} alt="ChatRPG" width="26" height="26" />
+            <span>ChatRPG</span>
+        </a>
+        <button class="close-btn" onclick={onClose} aria-label="Close menu">
+            <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M18 6L6 18M6 6l12 12"/>
+            </svg>
+        </button>
+    </div>
+
+    <div class="info-group">
+        <span class="info-label">Adventure</span>
+        <p class="info-value">{game.title}</p>
+    </div>
+
+    <div class="info-group">
+        <span class="info-label">Character</span>
+        <p class="info-value character">{character.name}</p>
+    </div>
+
+    <div class="info-group">
+        <span class="info-label">Tokens remaining</span>
+        <div class="token-display" class:low={tokensRemaining < 500}>
+            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <circle cx="12" cy="12" r="10"/>
+                <path d="M12 6v12M9 9h4.5a2.5 2.5 0 0 1 0 5H9"/>
+            </svg>
+            {tokensRemaining.toLocaleString()}
+            {#if tokensRemaining < 500}
+                <span class="low-warning">— running low</span>
+            {/if}
+        </div>
+    </div>
+
+    <div class="sidebar-sep"></div>
+
+    <nav class="sidebar-nav">
+        <a href="/game" class="nav-item">
+            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
+                <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
+            </svg>
+            My Games
+        </a>
+        <a href="/tokens" class="nav-item">
+            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <circle cx="12" cy="12" r="10"/>
+                <path d="M12 6v12M9 9h4.5a2.5 2.5 0 0 1 0 5H9"/>
+            </svg>
+            Add Tokens
+        </a>
+    </nav>
+
+    <button class="logout-btn" onclick={logout}>
+        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+            <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
+            <polyline points="16 17 21 12 16 7"/>
+            <line x1="21" y1="12" x2="9" y2="12"/>
+        </svg>
+        Log Out
+    </button>
+</aside>
+
+<style>
+    .sidebar {
+        width: 220px;
+        flex-shrink: 0;
+        height: 100vh;
+        display: flex;
+        flex-direction: column;
+        background: #0d0d16;
+        border-right: 1px solid #1a1a26;
+        padding: 1.25rem 1rem;
+        gap: 0;
+        overflow-y: auto;
+    }
+
+    /* ── Header ── */
+    .sidebar-header {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        margin-bottom: 2rem;
+    }
+
+    .brand {
+        display: flex;
+        align-items: center;
+        gap: 9px;
+        text-decoration: none;
+        color: #e5e5ea;
+        font-weight: 700;
+        font-size: 1rem;
+        letter-spacing: -0.3px;
+    }
+
+    .close-btn {
+        display: none;
+        background: none;
+        border: none;
+        color: #6b6b7a;
+        cursor: pointer;
+        padding: 4px;
+        border-radius: 6px;
+        transition: color 0.15s;
+    }
+
+    .close-btn:hover { color: #e5e5ea; }
+
+    /* ── Info groups ── */
+    .info-group {
+        padding: 0.75rem 0.5rem;
+        border-bottom: 1px solid #14141f;
+    }
+
+    .info-label {
+        display: block;
+        font-size: 0.63rem;
+        font-weight: 700;
+        letter-spacing: 0.1em;
+        text-transform: uppercase;
+        color: #44445a;
+        margin-bottom: 0.35rem;
+    }
+
+    .info-value {
+        font-size: 0.9rem;
+        font-weight: 600;
+        color: #e5e5ea;
+        line-height: 1.4;
+        word-break: break-word;
+    }
+
+    .info-value.character {
+        color: #c084fc;
+    }
+
+    .token-display {
+        display: flex;
+        align-items: center;
+        gap: 5px;
+        font-size: 0.9rem;
+        font-weight: 600;
+        color: #6b6b7a;
+        flex-wrap: wrap;
+    }
+
+    .token-display.low {
+        color: #e05555;
+    }
+
+    .low-warning {
+        font-size: 0.75rem;
+        font-weight: 500;
+        color: #e05555;
+        opacity: 0.8;
+    }
+
+    /* ── Separator ── */
+    .sidebar-sep {
+        flex: 1;
+    }
+
+    /* ── Nav ── */
+    .sidebar-nav {
+        display: flex;
+        flex-direction: column;
+        gap: 2px;
+        margin-bottom: 0.5rem;
+    }
+
+    .nav-item {
+        display: flex;
+        align-items: center;
+        gap: 9px;
+        padding: 0.55rem 0.6rem;
+        border-radius: 8px;
+        font-size: 0.875rem;
+        font-weight: 500;
+        color: #6b6b7a;
+        text-decoration: none;
+        transition: background 0.15s, color 0.15s;
+    }
+
+    .nav-item:hover {
+        background: #14141f;
+        color: #e5e5ea;
+    }
+
+    /* ── Logout ── */
+    .logout-btn {
+        display: flex;
+        align-items: center;
+        gap: 9px;
+        padding: 0.55rem 0.6rem;
+        border-radius: 8px;
+        font-size: 0.875rem;
+        font-weight: 500;
+        color: #6b6b7a;
+        background: none;
+        border: none;
+        cursor: pointer;
+        font-family: inherit;
+        width: 100%;
+        transition: background 0.15s, color 0.15s;
+    }
+
+    .logout-btn:hover {
+        background: #14141f;
+        color: #e05555;
+    }
+
+    /* ── Mobile ── */
+    @media (max-width: 768px) {
+        .sidebar {
+            position: fixed;
+            top: 0;
+            left: 0;
+            z-index: 200;
+            width: 260px;
+            transform: translateX(-100%);
+            transition: transform 0.25s ease;
+            box-shadow: none;
+        }
+
+        .sidebar.open {
+            transform: translateX(0);
+            box-shadow: 4px 0 40px rgba(0, 0, 0, 0.5);
+        }
+
+        .close-btn { display: flex; }
+    }
+</style>

+ 21 - 9
src/routes/(app)/game/new/+page.svelte

@@ -40,20 +40,32 @@
             return;
         }
 
-        let characterBody = {
-            name: characterName,
-            description: characterDescription
-        };
+        const sessionProm = fetch(`${PUBLIC_API_URL}/game/${gameData.id}/session`, {
+            method: "POST",
+            headers: {"Content-Type": "application/json"},
+            credentials: "include"
+        });
 
-        const charRes = await fetch(`${PUBLIC_API_URL}/game/${gameData.id}/character`, {
+        const charProm = fetch(`${PUBLIC_API_URL}/game/${gameData.id}/character`, {
             method: "POST",
             headers: {"Content-Type": "application/json"},
             credentials: "include",
-            body: JSON.stringify(characterBody)
+            body: JSON.stringify({
+                name: characterName,
+                description: characterDescription
+            })
         });
 
-        const charData = await charRes.json();
-        if (!charRes.ok) {
+        const [sessionRes, charRes] = await Promise.all([sessionProm, charProm]);
+        const [sessionData, charData] = await Promise.all([sessionRes.json(), charRes.json()]);
+        
+        if(!sessionRes.ok){
+            toast(sessionData.error.message, "error");
+            if(sessionRes.status === 401) goto("/");
+            return;
+        }
+
+        if(!charRes.ok){
             toast(charData.error.message, "error");
             if(charRes.status === 401) goto("/");
             return;
@@ -101,7 +113,7 @@
         {#if step === 1}
             <Game bind:title bind:gameContext onNext={nextStep} />
         {:else}
-            <Character bind:name={characterName} bind:description={characterDescription} onSubmit={submit} />
+            <Character bind:name={characterName} bind:description={characterDescription} onSubmit={submit} onBack={() => step = 1} />
         {/if}
     </main>
 </div>

+ 31 - 2
src/routes/(app)/game/new/Character.svelte

@@ -1,5 +1,5 @@
 <script>
-    let { name = $bindable(""), description = $bindable(""), onSubmit } = $props();
+    let { name = $bindable(""), description = $bindable(""), onSubmit, onBack } = $props();
 
     const autofocus = (node) => { node.focus(); };
 </script>
@@ -23,7 +23,15 @@
         ></textarea>
     </label>
 
-    <button type="submit">Begin Adventure</button>
+    <div class="form-actions">
+        <button type="button" onclick={onBack}>
+            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M19 12H5M12 5l-7 7 7 7"/>
+            </svg>
+            Back
+        </button>
+        <button type="submit">Begin Adventure</button>
+    </div>
 </form>
 </div>
 
@@ -58,8 +66,29 @@
         font-size: 1rem;
     }
 
+    .form-actions {
+        display: flex;
+        gap: 0.75rem;
+    }
+
+    .form-actions button {
+        width: auto;
+    }
+
+    .form-actions button:first-child {
+        display: inline-flex;
+        align-items: center;
+        gap: 6px;
+        flex-shrink: 0;
+    }
+
+    .form-actions button[type="submit"] {
+        flex: 1;
+    }
+
     @media (max-width: 700px) {
         .standardForm { padding: 1.75rem; }
         textarea { min-height: 160px; }
+        .form-actions { flex-direction: column-reverse; }
     }
 </style>

+ 37 - 0
src/routes/api/game/[game_id]/session/[session_id]/turn/+server.js

@@ -0,0 +1,37 @@
+import {PRIVATE_API_URL} from "$env/static/private";
+import {fetch} from "undici";
+
+export async function POST({params, request, cookies}){
+    const cookie = cookies.get("user");
+    const body = await request.json();
+
+    const res = await fetch(`${PRIVATE_API_URL}/game/${params.game_id}/session/${params.session_id}/turn`, {
+        method: "POST",
+        headers: {
+            "Content-Type": "application/json",
+            "Cookie": `user=${cookie}`,
+            "Accept-Encoding": "identity"
+        },
+        body: JSON.stringify(body),
+        duplex: "half"
+    });
+
+    const reader = res.body.getReader();
+    const stream = new ReadableStream({
+        async start(controller) {
+            while (true) {
+                const { done, value } = await reader.read();
+                if (done) break;
+                controller.enqueue(value);
+            }
+            controller.close();
+        }
+    });
+
+    return new Response(stream, {
+        headers: {
+            "Content-Type": "text/event-stream",
+            "Cache-Control": "no-cache"
+        }
+    });
+}

+ 6 - 1
vite.config.js

@@ -17,7 +17,12 @@ export default defineConfig({
             "/api": {
                 target: "https://chatrpgapi.leemorgan.dev",
                 changeOrigin: true,
-                rewrite: (path) => path.replace(/^\/api/, "")
+                rewrite: (path) => path.replace(/^\/api/, ""),
+                bypass: (req) => {
+                    if (req.url.match(/\/api\/game\/[^/]+\/session\/[^/]+\/turn/)) {
+                        return req.url;
+                    }
+                }
             }
         }
     }