mirror of
https://github.com/temetro/temetro.git
synced 2026-08-20 15:12:18 +00:00
Build temetro app: dark theme, clinical sidebar, AI chat, settings page
- Linear-inspired dark theme (app/globals.css), forced dark in layout
- Clinical sidebar (components/sidebar-02) with temetro brand
- AI chat UI (components/chat): Cursor-style input, UI-only mock replies
- Settings page (components/settings): Polar-style tabs/sections, UI only
- Route-group app shell: app/(app)/{layout,page,settings}
- shadcn (Base UI) + ai-elements component libraries
- CLAUDE.md: architecture notes + per-folder commit policy
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
FolderGit2,
|
||||
GitBranch,
|
||||
Hand,
|
||||
Laptop,
|
||||
Mic,
|
||||
Plus,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import { type KeyboardEvent, useCallback, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChatInputProps = {
|
||||
onSubmit: (text: string) => void;
|
||||
status: ChatStatus;
|
||||
onStop?: () => void;
|
||||
};
|
||||
|
||||
const iconButton =
|
||||
"flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
|
||||
const pillButton =
|
||||
"flex h-8 items-center gap-1.5 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
|
||||
const contextPill =
|
||||
"flex h-7 items-center gap-1.5 rounded-md px-2 text-[13px] text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground";
|
||||
|
||||
export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const isGenerating = status === "submitted" || status === "streaming";
|
||||
const canSend = value.trim().length > 0 && !isGenerating;
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || isGenerating) {
|
||||
return;
|
||||
}
|
||||
onSubmit(trimmed);
|
||||
setValue("");
|
||||
}, [value, isGenerating, onSubmit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!event.shiftKey &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
},
|
||||
[submit]
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
className="w-full overflow-hidden rounded-[28px] border border-border/60 bg-[oklch(0.172_0.006_277)] shadow-sm"
|
||||
>
|
||||
{/* Top (lighter) card: textarea + toolbar, with a slightly smaller bottom radius */}
|
||||
<div className="rounded-b-[22px] bg-card">
|
||||
<textarea
|
||||
aria-label="Message"
|
||||
className="field-sizing-content block max-h-48 min-h-16 w-full resize-none bg-transparent px-5 pt-5 pb-2 text-base text-foreground outline-none placeholder:text-muted-foreground"
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Do anything"
|
||||
rows={1}
|
||||
value={value}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-3 pb-3">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<button aria-label="Add context" className={iconButton} type="button">
|
||||
<Plus className="size-[18px]" />
|
||||
</button>
|
||||
<button className={pillButton} type="button">
|
||||
<Hand className="size-4" />
|
||||
<span className="truncate">Default permissions</span>
|
||||
<ChevronDown className="size-4 opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button className={cn(pillButton, "mr-1")} type="button">
|
||||
<span className="font-medium text-foreground">Custom</span>
|
||||
<span>High</span>
|
||||
<ChevronDown className="size-4 opacity-70" />
|
||||
</button>
|
||||
<button aria-label="Voice input" className={iconButton} type="button">
|
||||
<Mic className="size-[18px]" />
|
||||
</button>
|
||||
<button
|
||||
aria-label={isGenerating ? "Stop" : "Send"}
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||
canSend || isGenerating
|
||||
? "bg-foreground text-background hover:bg-foreground/90"
|
||||
: "bg-muted-foreground/30 text-foreground/70"
|
||||
)}
|
||||
disabled={!(canSend || isGenerating)}
|
||||
onClick={isGenerating && onStop ? onStop : undefined}
|
||||
type={isGenerating && onStop ? "button" : "submit"}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Square className="size-3.5" />
|
||||
) : (
|
||||
<ArrowUp className="size-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom (darker) card peeking out below, more rounded: context selectors */}
|
||||
<div className="flex flex-wrap items-center gap-1 px-3 pt-2.5 pb-3">
|
||||
<button className={contextPill} type="button">
|
||||
<FolderGit2 className="size-4" />
|
||||
<span>design-ai</span>
|
||||
<ChevronDown className="size-3.5 opacity-70" />
|
||||
</button>
|
||||
<button className={contextPill} type="button">
|
||||
<Laptop className="size-4" />
|
||||
<span>Work locally</span>
|
||||
<ChevronDown className="size-3.5 opacity-70" />
|
||||
</button>
|
||||
<button className={contextPill} type="button">
|
||||
<GitBranch className="size-4" />
|
||||
<span>main</span>
|
||||
<ChevronDown className="size-3.5 opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
import type { ChatStatus } from "ai";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { ChatInput } from "@/components/chat/chat-input";
|
||||
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
};
|
||||
|
||||
const HEADING = "What should we build in design-ai?";
|
||||
|
||||
export function ChatPanel() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [status, setStatus] = useState<ChatStatus>("ready");
|
||||
|
||||
// UI-only: append the user's message and a mock assistant reply.
|
||||
const send = useCallback((text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: nanoid(), role: "user", text: trimmed },
|
||||
]);
|
||||
setStatus("submitted");
|
||||
|
||||
window.setTimeout(() => {
|
||||
setStatus("streaming");
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: nanoid(),
|
||||
role: "assistant",
|
||||
text: `This is a **UI-only preview** of temetro. Connecting to your records and a model comes next.\n\nYou asked:\n\n> ${trimmed}`,
|
||||
},
|
||||
]);
|
||||
window.setTimeout(() => setStatus("ready"), 250);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(() => setStatus("ready"), []);
|
||||
|
||||
const promptInput = (
|
||||
<ChatInput onStop={handleStop} onSubmit={send} status={status} />
|
||||
);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-4">
|
||||
<div className="flex w-full max-w-3xl flex-col items-center gap-10">
|
||||
<h1 className="text-center text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
||||
{HEADING}
|
||||
</h1>
|
||||
{promptInput}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Conversation>
|
||||
<ConversationContent className="mx-auto w-full max-w-3xl">
|
||||
{messages.map((message) => (
|
||||
<Message from={message.role} key={message.id}>
|
||||
<MessageContent>
|
||||
{message.role === "assistant" ? (
|
||||
<MessageResponse>{message.text}</MessageResponse>
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap">{message.text}</span>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
))}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
<div className="mx-auto w-full max-w-3xl px-4 pb-4">{promptInput}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user