1
0
Fork 0

Mobile adaptation

This commit is contained in:
Pabloader 2026-03-27 19:33:23 +00:00
parent d8b1893739
commit 5453b0513f
7 changed files with 114 additions and 24 deletions

View File

@ -5,6 +5,7 @@
padding: 8px; padding: 8px;
} }
.placeholder { .placeholder {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -62,6 +62,13 @@
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
padding: 0 12px; padding: 0 12px;
gap: 6px; gap: 6px;
overflow-x: auto;
flex-shrink: 0;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
} }
.tabRight { .tabRight {
@ -81,7 +88,11 @@
} }
.tab { .tab {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 16px; padding: 12px 16px;
flex-shrink: 0;
background: transparent; background: transparent;
border: none; border: none;
border-top: 2px solid transparent; border-top: 2px solid transparent;
@ -101,3 +112,21 @@
border-top-color: var(--accent); border-top-color: var(--accent);
} }
} }
@media (max-width: 1000px) {
.tabLabel {
display: none;
}
.tab {
padding: 12px;
}
.content {
padding: 0 16px 32px;
}
.title {
padding: 0 16px 16px;
}
}

View File

@ -33,6 +33,38 @@
min-width: 32px; min-width: 32px;
} }
.closed[data-controlled] {
width: 0;
min-width: 0;
border: none;
}
.toggleMobile {
display: none;
}
@media (max-width: 1000px) {
.toggleMobile {
display: flex;
}
}
@media (max-width: 1000px) {
.mobileOverlay {
position: fixed;
top: 0;
right: 0;
height: 100dvh;
z-index: 100;
border-left: none;
}
.mobileOverlay.open {
width: 100dvw;
min-width: 0;
}
}
.toggle { .toggle {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -4,6 +4,7 @@ import { Sidebar } from "./sidebar";
import { useAppState, type ChatMessage } from "../contexts/state"; import { useAppState, type ChatMessage } from "../contexts/state";
import { useChapterSummarization } from "../utils/useChapterSummarization"; import { useChapterSummarization } from "../utils/useChapterSummarization";
import styles from '../assets/chat-sidebar.module.css'; import styles from '../assets/chat-sidebar.module.css';
import sidebarStyles from '../assets/sidebar.module.css';
import { useState, useRef, useEffect, useMemo, useCallback } from "preact/hooks"; import { useState, useRef, useEffect, useMemo, useCallback } from "preact/hooks";
import LLM from "../utils/llm"; import LLM from "../utils/llm";
import Prompt from "../utils/prompt"; import Prompt from "../utils/prompt";
@ -43,11 +44,10 @@ const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => {
export const ChatSidebar = () => { export const ChatSidebar = () => {
const appState = useAppState(); const appState = useAppState();
const { currentStory, dispatch, connection, model, enableThinking } = appState; const { currentStory, dispatch, connection, model, enableThinking, chatOpen } = appState;
const { summarizeAll, isSummarizing } = useChapterSummarization(); const { summarizeAll, isSummarizing } = useChapterSummarization();
const [input, setInput] = useInputState(''); const [input, setInput] = useInputState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isCollapsed, setCollapsed] = useState(false);
const [tokenCount, setTokenCount] = useState<{ taken: number; total: number } | null>(null); const [tokenCount, setTokenCount] = useState<{ taken: number; total: number } | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -76,7 +76,7 @@ export const ChatSidebar = () => {
if (messagesRef.current) { if (messagesRef.current) {
messagesRef.current.scrollTop = messagesRef.current.scrollHeight; messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
} }
}, [isCollapsed]); }, [chatOpen]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -301,7 +301,7 @@ export const ChatSidebar = () => {
const isDisabled = !currentStory || !connection || !model || isLoading; const isDisabled = !currentStory || !connection || !model || isLoading;
return ( return (
<Sidebar side="right" onCollapseChanged={setCollapsed}> <Sidebar side="right" open={chatOpen} onToggle={() => dispatch({ type: 'SET_CHAT_OPEN', open: !chatOpen })} class={sidebarStyles.mobileOverlay}>
<div class={styles.chat}> <div class={styles.chat}>
{!currentStory ? ( {!currentStory ? (
<div class={styles.placeholder}> <div class={styles.placeholder}>

View File

@ -11,21 +11,22 @@ import { LoreEditor } from "./lore-editor";
import { Menu } from "./menu"; import { Menu } from "./menu";
import { useInputCallback } from "@common/hooks/useInputCallback"; import { useInputCallback } from "@common/hooks/useInputCallback";
import Prompt from "../utils/prompt"; import Prompt from "../utils/prompt";
import { BookOpen, List, Users, MapPin, BookMarked, FileText, Code, Layers, MessageSquare, type LucideIcon } from "lucide-preact";
const TABS: { id: Tab; label: string; right?: boolean }[] = [ const TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
{ id: "menu", label: "Menu" }, { id: "menu", label: "Menu", icon: List },
{ id: "story", label: "Story" }, { id: "story", label: "Story", icon: BookOpen },
{ id: "chapters", label: "Chapters" }, { id: "chapters", label: "Chapters", icon: Layers },
{ id: "lore", label: "Lore" }, { id: "lore", label: "Lore", icon: BookMarked },
{ id: "characters", label: "Characters" }, { id: "characters", label: "Characters", icon: Users },
{ id: "locations", label: "Locations" }, { id: "locations", label: "Locations", icon: MapPin },
{ id: "scratchpad", label: "Scratchpad", right: true }, { id: "scratchpad", label: "Scratchpad", icon: FileText, right: true },
{ id: "prompt", label: "Prompt" }, { id: "prompt", label: "Prompt", icon: Code },
]; ];
export const Editor = () => { export const Editor = () => {
const appState = useAppState(); const appState = useAppState();
const { currentStory, currentTab, dispatch } = appState; const { currentStory, currentTab, chatOpen, dispatch } = appState;
const handleInput = useInputCallback((text: string) => { const handleInput = useInputCallback((text: string) => {
if (!currentStory) return; if (!currentStory) return;
@ -135,10 +136,20 @@ export const Editor = () => {
key={tab.id} key={tab.id}
class={clsx(styles.tab, currentTab === tab.id && styles.active, tab.right && styles.tabRight)} class={clsx(styles.tab, currentTab === tab.id && styles.active, tab.right && styles.tabRight)}
onClick={() => handleTabChange(tab.id)} onClick={() => handleTabChange(tab.id)}
title={tab.label}
> >
{tab.label} <tab.icon size={15} />
<span class={styles.tabLabel}>{tab.label}</span>
</button> </button>
))} ))}
<button
class={clsx(styles.tab, styles.tabRight, chatOpen && styles.active)}
onClick={() => dispatch({ type: 'SET_CHAT_OPEN', open: !chatOpen })}
title="Chat"
>
<MessageSquare size={15} />
<span class={styles.tabLabel}>Chat</span>
</button>
</div> </div>
</div> </div>
); );

View File

@ -7,28 +7,37 @@ import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "
interface Props { interface Props {
side: 'left' | 'right'; side: 'left' | 'right';
children?: ComponentChildren; children?: ComponentChildren;
open?: boolean;
onToggle?: () => void;
onCollapseChanged?: (collapsed: boolean) => void; onCollapseChanged?: (collapsed: boolean) => void;
class?: string;
} }
export const Sidebar = ({ side, children, onCollapseChanged }: Props) => { export const Sidebar = ({ side, children, open: controlledOpen, onToggle, onCollapseChanged, class: className }: Props) => {
const open = useBool(true); const internalOpen = useBool(true);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : internalOpen.value;
const handleToggle = () => { const handleToggle = () => {
open.toggle(); if (isControlled) {
onCollapseChanged?.(!open.value); onToggle?.();
} else {
internalOpen.toggle();
}
onCollapseChanged?.(!isOpen);
}; };
const isLeft = side === 'left'; const isLeft = side === 'left';
const IconComponent = isLeft const IconComponent = isLeft
? (open.value ? PanelLeftClose : PanelLeftOpen) ? (isOpen ? PanelLeftClose : PanelLeftOpen)
: (open.value ? PanelRightClose : PanelRightOpen); : (isOpen ? PanelRightClose : PanelRightOpen);
return ( return (
<div class={clsx(styles.sidebar, open.value ? styles.open : styles.closed)} data-side={side}> <div class={clsx(styles.sidebar, isOpen ? styles.open : styles.closed, className)} data-side={side} data-controlled={isControlled || undefined}>
<button class={styles.toggle} onClick={handleToggle}> <button class={clsx(styles.toggle, isControlled && styles.toggleMobile)} onClick={handleToggle}>
<IconComponent size={16} /> <IconComponent size={16} />
</button> </button>
{open.value && ( {isOpen && (
<div class={styles.content}> <div class={styles.content}>
{children} {children}
</div> </div>

View File

@ -83,6 +83,7 @@ interface IState {
stories: Story[]; stories: Story[];
currentStoryId: string | null; currentStoryId: string | null;
currentTab: Tab; currentTab: Tab;
chatOpen: boolean;
connection: LLM.Connection | null; connection: LLM.Connection | null;
model: LLM.ModelInfo | null; model: LLM.ModelInfo | null;
enableThinking: boolean; enableThinking: boolean;
@ -103,6 +104,7 @@ type Action =
| { type: 'REORDER_LORE_ENTRIES'; storyId: string; entryIds: string[] } | { type: 'REORDER_LORE_ENTRIES'; storyId: string; entryIds: string[] }
| { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string } | { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string }
| { type: 'SET_CURRENT_TAB'; tab: Tab } | { type: 'SET_CURRENT_TAB'; tab: Tab }
| { type: 'SET_CHAT_OPEN'; open: boolean }
| { type: 'DELETE_STORY'; id: string } | { type: 'DELETE_STORY'; id: string }
| { type: 'SELECT_STORY'; id: string } | { type: 'SELECT_STORY'; id: string }
| { type: 'DUPLICATE_STORY'; id: string } | { type: 'DUPLICATE_STORY'; id: string }
@ -130,6 +132,7 @@ const DEFAULT_STATE: IState = {
stories: [], stories: [],
currentStoryId: null, currentStoryId: null,
currentTab: 'menu', currentTab: 'menu',
chatOpen: false,
connection: null, connection: null,
model: null, model: null,
enableThinking: false, enableThinking: false,
@ -263,6 +266,9 @@ function reducer(state: IState, action: Action): IState {
case 'SET_CURRENT_TAB': { case 'SET_CURRENT_TAB': {
return { ...state, currentTab: action.tab }; return { ...state, currentTab: action.tab };
} }
case 'SET_CHAT_OPEN': {
return { ...state, chatOpen: action.open };
}
case 'DELETE_STORY': { case 'DELETE_STORY': {
const remaining = state.stories.filter(s => s.id !== action.id); const remaining = state.stories.filter(s => s.id !== action.id);
const deletingCurrent = state.currentStoryId === action.id; const deletingCurrent = state.currentStoryId === action.id;
@ -539,6 +545,7 @@ export interface AppState {
stories: Story[]; stories: Story[];
currentStory: Story | null; currentStory: Story | null;
currentTab: Tab; currentTab: Tab;
chatOpen: boolean;
connection: LLM.Connection | null; connection: LLM.Connection | null;
model: LLM.ModelInfo | null; model: LLM.ModelInfo | null;
enableThinking: boolean; enableThinking: boolean;
@ -560,6 +567,7 @@ export const StateContextProvider = ({ children }: { children?: any }) => {
stories: state.stories, stories: state.stories,
currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null, currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null,
currentTab: state.currentTab, currentTab: state.currentTab,
chatOpen: state.chatOpen,
connection: state.connection, connection: state.connection,
model: state.model, model: state.model,
enableThinking: state.enableThinking, enableThinking: state.enableThinking,