feat(web): add product site and app surface
This commit is contained in:
parent
b925723fa6
commit
a650538b55
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,6 +2,8 @@ node_modules/
|
||||
apps/web/dist/
|
||||
packages/sdk-ts/dist/
|
||||
dist/
|
||||
!apps/api/internal/webassets/dist/
|
||||
!apps/api/internal/webassets/dist/**
|
||||
data/
|
||||
coverage.out
|
||||
coverage.txt
|
||||
|
||||
@ -2,11 +2,14 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Added a public product website at the web root while keeping the chat app at
|
||||
`/app` locally and on `app.clickclack.chat` when served from that host.
|
||||
- Added an agent-friendly ClickClack client mode to the Go binary with
|
||||
`login`, `logout`, `whoami`, `status`, workspace/channel listing, message
|
||||
send/list, and thread open/reply commands.
|
||||
- Scoped stored CLI credentials and workspace/channel defaults to the saved
|
||||
server URL, with `--user` / `CLICKCLACK_USER_ID` taking precedence over
|
||||
stored bearer tokens unless `--token` is explicitly supplied.
|
||||
- Documented the `clickclack.chat` product domain, `docs.clickclack.chat`
|
||||
docs domain, and recommended bearer-token auth flow for hosted agents.
|
||||
- Documented the `clickclack.chat` product domain, `app.clickclack.chat` app
|
||||
domain, `docs.clickclack.chat` docs domain, and recommended bearer-token auth
|
||||
flow for hosted agents.
|
||||
|
||||
@ -31,7 +31,9 @@ go run ./apps/api/cmd/clickclack serve
|
||||
|
||||
## Documentation
|
||||
|
||||
Product domain: **[clickclack.chat](https://clickclack.chat)**. Docs domain:
|
||||
Product domain: **[clickclack.chat](https://clickclack.chat)**. App domain:
|
||||
**[app.clickclack.chat](https://app.clickclack.chat)**, with `/app` as the
|
||||
local path. Docs domain:
|
||||
**[docs.clickclack.chat](https://docs.clickclack.chat)**, built from `docs/`
|
||||
by `pnpm docs:site`. The [docs/](docs/) tree is organised so each file has a
|
||||
short `read_when` hint at the top — open the one that matches your change.
|
||||
@ -74,8 +76,9 @@ go run ./apps/api/cmd/clickclack serve # http://localhost:8080
|
||||
```
|
||||
|
||||
The dev fallback boots a default user, workspace, and channel so the SPA
|
||||
loads into something useful on first hit. Disable it with
|
||||
`--dev-bootstrap=false` for anything that isn't a local checkout.
|
||||
loads into something useful at `/app`. The root path is the public product
|
||||
website. Disable it with `--dev-bootstrap=false` for anything that isn't a
|
||||
local checkout.
|
||||
|
||||
### Two-process dev loop
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
apps/api/internal/webassets/dist/assets/index-CljoeKn6.css
vendored
Normal file
1
apps/api/internal/webassets/dist/assets/index-CljoeKn6.css
vendored
Normal file
File diff suppressed because one or more lines are too long
68
apps/api/internal/webassets/dist/assets/index-DIRqaQC7.js
vendored
Normal file
68
apps/api/internal/webassets/dist/assets/index-DIRqaQC7.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
apps/api/internal/webassets/dist/index.html
vendored
4
apps/api/internal/webassets/dist/index.html
vendored
@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ClickClack</title>
|
||||
<script type="module" crossorigin src="/assets/index-4rv34_La.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Du33dVG9.css">
|
||||
<script type="module" crossorigin src="/assets/index-DIRqaQC7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CljoeKn6.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@ -1,472 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { api } from "./lib/api";
|
||||
import { markdown, time } from "./lib/format";
|
||||
import type { Channel, DirectConversation, Message, RealtimeEvent, SearchResult, ThreadState, Upload, User, Workspace } from "./lib/types";
|
||||
|
||||
let user: User | null = null;
|
||||
let workspaces: Workspace[] = [];
|
||||
let channels: Channel[] = [];
|
||||
let directConversations: DirectConversation[] = [];
|
||||
let messages: Message[] = [];
|
||||
let replies: Message[] = [];
|
||||
let selectedWorkspaceID = "";
|
||||
let selectedChannelID = "";
|
||||
let selectedDirectID = "";
|
||||
let selectedThread: Message | null = null;
|
||||
let selectedThreadState: ThreadState | null = null;
|
||||
let messageBody = "";
|
||||
let replyBody = "";
|
||||
let workspaceName = "";
|
||||
let channelName = "";
|
||||
let directMemberID = "";
|
||||
let searchQuery = "";
|
||||
let searchResults: SearchResult[] = [];
|
||||
let pendingUpload: Upload | null = null;
|
||||
let status = "loading";
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectTimer: number | undefined;
|
||||
|
||||
$: selectedWorkspace = workspaces.find((workspace) => workspace.id === selectedWorkspaceID);
|
||||
$: selectedChannel = channels.find((channel) => channel.id === selectedChannelID);
|
||||
$: selectedDirect = directConversations.find((conversation) => conversation.id === selectedDirectID);
|
||||
|
||||
onMount(() => {
|
||||
void boot();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
socket?.close();
|
||||
if (reconnectTimer) window.clearTimeout(reconnectTimer);
|
||||
});
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
const me = await api<{ user: User }>("/api/me");
|
||||
user = me.user;
|
||||
await loadWorkspaces();
|
||||
status = "ready";
|
||||
} catch (error) {
|
||||
status = error instanceof Error ? error.message : "Could not load ClickClack";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkspaces() {
|
||||
const data = await api<{ workspaces: Workspace[] }>("/api/workspaces");
|
||||
workspaces = data.workspaces;
|
||||
selectedWorkspaceID = selectedWorkspaceID || workspaces[0]?.id || "";
|
||||
await loadChannels();
|
||||
await loadDirectConversations();
|
||||
connectRealtime();
|
||||
}
|
||||
|
||||
async function createWorkspace() {
|
||||
if (!workspaceName.trim()) return;
|
||||
const data = await api<{ workspace: Workspace }>("/api/workspaces", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: workspaceName })
|
||||
});
|
||||
workspaceName = "";
|
||||
workspaces = [...workspaces, data.workspace];
|
||||
selectedWorkspaceID = data.workspace.id;
|
||||
await loadChannels();
|
||||
await loadDirectConversations();
|
||||
connectRealtime();
|
||||
}
|
||||
|
||||
async function loadChannels() {
|
||||
if (!selectedWorkspaceID) return;
|
||||
const data = await api<{ channels: Channel[] }>(`/api/workspaces/${selectedWorkspaceID}/channels`);
|
||||
channels = data.channels;
|
||||
selectedChannelID = channels.find((channel) => channel.id === selectedChannelID)?.id || channels[0]?.id || "";
|
||||
selectedThread = null;
|
||||
replies = [];
|
||||
await loadMessages();
|
||||
}
|
||||
|
||||
async function createChannel() {
|
||||
if (!selectedWorkspaceID || !channelName.trim()) return;
|
||||
const data = await api<{ channel: Channel }>(`/api/workspaces/${selectedWorkspaceID}/channels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: channelName, kind: "public" })
|
||||
});
|
||||
channelName = "";
|
||||
channels = [...channels, data.channel];
|
||||
selectedChannelID = data.channel.id;
|
||||
await loadMessages();
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
if (selectedDirectID) {
|
||||
const data = await api<{ messages: Message[] }>(`/api/dms/${selectedDirectID}/messages`);
|
||||
messages = data.messages;
|
||||
return;
|
||||
}
|
||||
if (!selectedChannelID) {
|
||||
messages = [];
|
||||
return;
|
||||
}
|
||||
const data = await api<{ messages: Message[] }>(`/api/channels/${selectedChannelID}/messages`);
|
||||
messages = data.messages;
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const body = messageBody.trim();
|
||||
if (!body || (!selectedChannelID && !selectedDirectID)) return;
|
||||
messageBody = "";
|
||||
const path = selectedDirectID ? `/api/dms/${selectedDirectID}/messages` : `/api/channels/${selectedChannelID}/messages`;
|
||||
const data = await api<{ message: Message }>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body })
|
||||
});
|
||||
if (pendingUpload) {
|
||||
await api(`/api/messages/${data.message.id}/attachments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ upload_id: pendingUpload.id })
|
||||
});
|
||||
pendingUpload = null;
|
||||
}
|
||||
if (!messages.some((message) => message.id === data.message.id)) {
|
||||
messages = [...messages, data.message];
|
||||
}
|
||||
}
|
||||
|
||||
async function openThread(message: Message) {
|
||||
selectedThread = message;
|
||||
const data = await api<{ root: Message; replies: Message[]; thread_state: ThreadState }>(`/api/messages/${message.id}/thread`);
|
||||
selectedThread = data.root;
|
||||
replies = data.replies;
|
||||
selectedThreadState = data.thread_state;
|
||||
}
|
||||
|
||||
async function sendReply() {
|
||||
const body = replyBody.trim();
|
||||
if (!body || !selectedThread) return;
|
||||
replyBody = "";
|
||||
const data = await api<{ message: Message; thread_state: ThreadState }>(`/api/messages/${selectedThread.id}/thread/replies`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body })
|
||||
});
|
||||
if (!replies.some((reply) => reply.id === data.message.id)) {
|
||||
replies = [...replies, data.message];
|
||||
}
|
||||
selectedThreadState = data.thread_state;
|
||||
}
|
||||
|
||||
async function searchMessages() {
|
||||
if (!selectedWorkspaceID || !searchQuery.trim()) {
|
||||
searchResults = [];
|
||||
return;
|
||||
}
|
||||
const data = await api<{ results: SearchResult[] }>(
|
||||
`/api/search?workspace_id=${encodeURIComponent(selectedWorkspaceID)}&q=${encodeURIComponent(searchQuery.trim())}`
|
||||
);
|
||||
searchResults = data.results;
|
||||
}
|
||||
|
||||
async function uploadFile(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file || !selectedWorkspaceID) return;
|
||||
const form = new FormData();
|
||||
form.set("workspace_id", selectedWorkspaceID);
|
||||
form.set("file", file);
|
||||
const data = await api<{ upload: Upload }>("/api/uploads", { method: "POST", body: form });
|
||||
pendingUpload = data.upload;
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
async function loadDirectConversations() {
|
||||
if (!selectedWorkspaceID) return;
|
||||
const data = await api<{ conversations: DirectConversation[] }>(`/api/dms?workspace_id=${selectedWorkspaceID}`);
|
||||
directConversations = data.conversations;
|
||||
}
|
||||
|
||||
async function createDirectConversation() {
|
||||
if (!selectedWorkspaceID || !directMemberID.trim()) return;
|
||||
const data = await api<{ conversation: DirectConversation }>("/api/dms", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workspace_id: selectedWorkspaceID, member_ids: [directMemberID.trim()] })
|
||||
});
|
||||
directMemberID = "";
|
||||
directConversations = [...directConversations, data.conversation];
|
||||
selectedDirectID = data.conversation.id;
|
||||
selectedChannelID = "";
|
||||
selectedThread = null;
|
||||
await loadMessages();
|
||||
}
|
||||
|
||||
function connectRealtime() {
|
||||
socket?.close();
|
||||
if (!selectedWorkspaceID) return;
|
||||
const lastCursor = localStorage.getItem(`clickclack:${selectedWorkspaceID}:cursor`) || "";
|
||||
const url = new URL("/api/realtime/ws", window.location.href);
|
||||
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
url.searchParams.set("workspace_id", selectedWorkspaceID);
|
||||
if (lastCursor) url.searchParams.set("after_cursor", lastCursor);
|
||||
socket = new WebSocket(url);
|
||||
socket.addEventListener("message", (message) => {
|
||||
const event = JSON.parse(String(message.data)) as RealtimeEvent;
|
||||
if (event.cursor) localStorage.setItem(`clickclack:${selectedWorkspaceID}:cursor`, event.cursor);
|
||||
void handleEvent(event);
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
reconnectTimer = window.setTimeout(connectRealtime, 1200);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEvent(event: RealtimeEvent) {
|
||||
if ((event.type === "channel.created" || event.type === "channel.updated") && event.workspace_id === selectedWorkspaceID) {
|
||||
await loadChannels();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(event.channel_id === selectedChannelID || event.payload.direct_conversation_id === selectedDirectID) &&
|
||||
(event.type === "message.created" || event.type === "message.updated" || event.type === "message.deleted")
|
||||
) {
|
||||
await loadMessages();
|
||||
}
|
||||
const rootID = event.payload.root_message_id || event.payload.message_id;
|
||||
if (selectedThread && rootID === selectedThread.id) {
|
||||
await openThread(selectedThread);
|
||||
}
|
||||
}
|
||||
import ChatApp from "./ChatApp.svelte";
|
||||
import ProductSite from "./ProductSite.svelte";
|
||||
|
||||
const path = window.location.pathname;
|
||||
const isAppHost = window.location.hostname.startsWith("app.");
|
||||
const isApp = isAppHost || path === "/app" || path.startsWith("/app/");
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="shell">
|
||||
<aside class="sidebar" aria-label="Workspace and channel navigation">
|
||||
<div class="brand">
|
||||
<div class="mark">cc</div>
|
||||
<div>
|
||||
<strong>ClickClack</strong>
|
||||
<span>{user?.display_name || "local"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="section-title">Workspaces</div>
|
||||
<div class="nav-list">
|
||||
{#each workspaces as workspace}
|
||||
<button
|
||||
class:active={workspace.id === selectedWorkspaceID}
|
||||
onclick={async () => {
|
||||
selectedWorkspaceID = workspace.id;
|
||||
await loadChannels();
|
||||
connectRealtime();
|
||||
}}
|
||||
>
|
||||
{workspace.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<form
|
||||
class="inline-create"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void createWorkspace();
|
||||
}}
|
||||
>
|
||||
<input bind:value={workspaceName} placeholder="New workspace" aria-label="New workspace name" />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">Channels</div>
|
||||
<div class="nav-list channels">
|
||||
{#each channels as channel}
|
||||
<button
|
||||
class:active={channel.id === selectedChannelID}
|
||||
onclick={async () => {
|
||||
selectedChannelID = channel.id;
|
||||
selectedThread = null;
|
||||
await loadMessages();
|
||||
}}
|
||||
>
|
||||
<span>#</span>{channel.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<form
|
||||
class="inline-create"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void createChannel();
|
||||
}}
|
||||
>
|
||||
<input bind:value={channelName} placeholder="New channel" aria-label="New channel name" />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">DMs</div>
|
||||
<div class="nav-list channels">
|
||||
{#each directConversations as conversation}
|
||||
<button
|
||||
class:active={conversation.id === selectedDirectID}
|
||||
onclick={async () => {
|
||||
selectedDirectID = conversation.id;
|
||||
selectedChannelID = "";
|
||||
selectedThread = null;
|
||||
await loadMessages();
|
||||
}}
|
||||
>
|
||||
<span>@</span>{conversation.members.map((member) => member.display_name).join(", ")}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<form
|
||||
class="inline-create"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void createDirectConversation();
|
||||
}}
|
||||
>
|
||||
<input bind:value={directMemberID} placeholder="Member user ID" aria-label="DM member user ID" />
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main class="timeline">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p>{selectedWorkspace?.name || "Workspace"}</p>
|
||||
<h1>{selectedDirect ? "@" + selectedDirect.members.map((member) => member.display_name).join(", ") : "#" + (selectedChannel?.name || "general")}</h1>
|
||||
</div>
|
||||
<form
|
||||
class="search"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void searchMessages();
|
||||
}}
|
||||
>
|
||||
<input bind:value={searchQuery} placeholder="Search" aria-label="Search messages" />
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<div class="connection" data-state={socket?.readyState === WebSocket.OPEN ? "live" : "idle"}>
|
||||
{socket?.readyState === WebSocket.OPEN ? "live" : status}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if searchResults.length > 0}
|
||||
<div class="search-results" aria-label="Search results">
|
||||
{#each searchResults as result (result.message.id)}
|
||||
<button
|
||||
onclick={async () => {
|
||||
searchResults = [];
|
||||
if (result.message.channel_id) {
|
||||
selectedChannelID = result.message.channel_id;
|
||||
selectedDirectID = "";
|
||||
await loadMessages();
|
||||
}
|
||||
if (result.message.direct_conversation_id) {
|
||||
selectedDirectID = result.message.direct_conversation_id;
|
||||
selectedChannelID = "";
|
||||
await loadMessages();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<strong>{result.message.author?.display_name || "Local User"}</strong>
|
||||
<span>{result.message.body}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="messages" aria-live="polite">
|
||||
{#if messages.length === 0}
|
||||
<div class="empty">
|
||||
<strong>Quiet tide.</strong>
|
||||
<span>Start with Markdown. Threads open from any root message.</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each messages as message (message.id)}
|
||||
<article class="message" class:selected={selectedThread?.id === message.id}>
|
||||
<div class="avatar">{message.author?.display_name?.slice(0, 1) || "c"}</div>
|
||||
<div class="message-body">
|
||||
<header>
|
||||
<strong>{message.author?.display_name || "Local User"}</strong>
|
||||
<time>{time(message.created_at)}</time>
|
||||
</header>
|
||||
<div class="markdown">{@html markdown(message.body)}</div>
|
||||
<button class="thread-button" onclick={() => openThread(message)}>Open thread</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="composer"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void sendMessage();
|
||||
}}
|
||||
>
|
||||
<textarea bind:value={messageBody} rows="3" placeholder="Message with Markdown" aria-label="Message body"></textarea>
|
||||
<div class="composer-actions">
|
||||
<label class="upload-button">
|
||||
<input type="file" aria-label="Upload file" onchange={uploadFile} />
|
||||
Upload
|
||||
</label>
|
||||
{#if pendingUpload}
|
||||
<span class="pending-upload">{pendingUpload.filename}</span>
|
||||
{/if}
|
||||
<button type="button" onclick={() => void sendMessage()}>Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<aside class="thread" class:open={selectedThread} aria-label="Thread pane">
|
||||
{#if selectedThread}
|
||||
<header>
|
||||
<div>
|
||||
<p>Thread</p>
|
||||
<strong>{selectedThreadState?.reply_count || replies.length} replies</strong>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close thread"
|
||||
onclick={() => {
|
||||
selectedThread = null;
|
||||
replies = [];
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</header>
|
||||
<article class="thread-root">
|
||||
<strong>{selectedThread.author?.display_name || "Local User"}</strong>
|
||||
<div class="markdown">{@html markdown(selectedThread.body)}</div>
|
||||
</article>
|
||||
<div class="reply-list">
|
||||
{#each replies as reply (reply.id)}
|
||||
<article class="reply">
|
||||
<header>
|
||||
<strong>{reply.author?.display_name || "Local User"}</strong>
|
||||
<time>{time(reply.created_at)}</time>
|
||||
</header>
|
||||
<div class="markdown">{@html markdown(reply.body)}</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
<form
|
||||
class="reply-composer"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void sendReply();
|
||||
}}
|
||||
>
|
||||
<textarea bind:value={replyBody} rows="3" placeholder="Reply in thread" aria-label="Reply body"></textarea>
|
||||
<button type="button" onclick={() => void sendReply()}>Reply</button>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="thread-empty">
|
||||
<strong>No thread open</strong>
|
||||
<span>Pick a message to keep the side conversation tidy.</span>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
{#if isApp}
|
||||
<ChatApp />
|
||||
{:else}
|
||||
<ProductSite />
|
||||
{/if}
|
||||
|
||||
472
apps/web/src/ChatApp.svelte
Normal file
472
apps/web/src/ChatApp.svelte
Normal file
@ -0,0 +1,472 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { api } from "./lib/api";
|
||||
import { markdown, time } from "./lib/format";
|
||||
import type { Channel, DirectConversation, Message, RealtimeEvent, SearchResult, ThreadState, Upload, User, Workspace } from "./lib/types";
|
||||
|
||||
let user: User | null = null;
|
||||
let workspaces: Workspace[] = [];
|
||||
let channels: Channel[] = [];
|
||||
let directConversations: DirectConversation[] = [];
|
||||
let messages: Message[] = [];
|
||||
let replies: Message[] = [];
|
||||
let selectedWorkspaceID = "";
|
||||
let selectedChannelID = "";
|
||||
let selectedDirectID = "";
|
||||
let selectedThread: Message | null = null;
|
||||
let selectedThreadState: ThreadState | null = null;
|
||||
let messageBody = "";
|
||||
let replyBody = "";
|
||||
let workspaceName = "";
|
||||
let channelName = "";
|
||||
let directMemberID = "";
|
||||
let searchQuery = "";
|
||||
let searchResults: SearchResult[] = [];
|
||||
let pendingUpload: Upload | null = null;
|
||||
let status = "loading";
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectTimer: number | undefined;
|
||||
|
||||
$: selectedWorkspace = workspaces.find((workspace) => workspace.id === selectedWorkspaceID);
|
||||
$: selectedChannel = channels.find((channel) => channel.id === selectedChannelID);
|
||||
$: selectedDirect = directConversations.find((conversation) => conversation.id === selectedDirectID);
|
||||
|
||||
onMount(() => {
|
||||
void boot();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
socket?.close();
|
||||
if (reconnectTimer) window.clearTimeout(reconnectTimer);
|
||||
});
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
const me = await api<{ user: User }>("/api/me");
|
||||
user = me.user;
|
||||
await loadWorkspaces();
|
||||
status = "ready";
|
||||
} catch (error) {
|
||||
status = error instanceof Error ? error.message : "Could not load ClickClack";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkspaces() {
|
||||
const data = await api<{ workspaces: Workspace[] }>("/api/workspaces");
|
||||
workspaces = data.workspaces;
|
||||
selectedWorkspaceID = selectedWorkspaceID || workspaces[0]?.id || "";
|
||||
await loadChannels();
|
||||
await loadDirectConversations();
|
||||
connectRealtime();
|
||||
}
|
||||
|
||||
async function createWorkspace() {
|
||||
if (!workspaceName.trim()) return;
|
||||
const data = await api<{ workspace: Workspace }>("/api/workspaces", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: workspaceName })
|
||||
});
|
||||
workspaceName = "";
|
||||
workspaces = [...workspaces, data.workspace];
|
||||
selectedWorkspaceID = data.workspace.id;
|
||||
await loadChannels();
|
||||
await loadDirectConversations();
|
||||
connectRealtime();
|
||||
}
|
||||
|
||||
async function loadChannels() {
|
||||
if (!selectedWorkspaceID) return;
|
||||
const data = await api<{ channels: Channel[] }>(`/api/workspaces/${selectedWorkspaceID}/channels`);
|
||||
channels = data.channels;
|
||||
selectedChannelID = channels.find((channel) => channel.id === selectedChannelID)?.id || channels[0]?.id || "";
|
||||
selectedThread = null;
|
||||
replies = [];
|
||||
await loadMessages();
|
||||
}
|
||||
|
||||
async function createChannel() {
|
||||
if (!selectedWorkspaceID || !channelName.trim()) return;
|
||||
const data = await api<{ channel: Channel }>(`/api/workspaces/${selectedWorkspaceID}/channels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: channelName, kind: "public" })
|
||||
});
|
||||
channelName = "";
|
||||
channels = [...channels, data.channel];
|
||||
selectedChannelID = data.channel.id;
|
||||
await loadMessages();
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
if (selectedDirectID) {
|
||||
const data = await api<{ messages: Message[] }>(`/api/dms/${selectedDirectID}/messages`);
|
||||
messages = data.messages;
|
||||
return;
|
||||
}
|
||||
if (!selectedChannelID) {
|
||||
messages = [];
|
||||
return;
|
||||
}
|
||||
const data = await api<{ messages: Message[] }>(`/api/channels/${selectedChannelID}/messages`);
|
||||
messages = data.messages;
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const body = messageBody.trim();
|
||||
if (!body || (!selectedChannelID && !selectedDirectID)) return;
|
||||
messageBody = "";
|
||||
const path = selectedDirectID ? `/api/dms/${selectedDirectID}/messages` : `/api/channels/${selectedChannelID}/messages`;
|
||||
const data = await api<{ message: Message }>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body })
|
||||
});
|
||||
if (pendingUpload) {
|
||||
await api(`/api/messages/${data.message.id}/attachments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ upload_id: pendingUpload.id })
|
||||
});
|
||||
pendingUpload = null;
|
||||
}
|
||||
if (!messages.some((message) => message.id === data.message.id)) {
|
||||
messages = [...messages, data.message];
|
||||
}
|
||||
}
|
||||
|
||||
async function openThread(message: Message) {
|
||||
selectedThread = message;
|
||||
const data = await api<{ root: Message; replies: Message[]; thread_state: ThreadState }>(`/api/messages/${message.id}/thread`);
|
||||
selectedThread = data.root;
|
||||
replies = data.replies;
|
||||
selectedThreadState = data.thread_state;
|
||||
}
|
||||
|
||||
async function sendReply() {
|
||||
const body = replyBody.trim();
|
||||
if (!body || !selectedThread) return;
|
||||
replyBody = "";
|
||||
const data = await api<{ message: Message; thread_state: ThreadState }>(`/api/messages/${selectedThread.id}/thread/replies`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body })
|
||||
});
|
||||
if (!replies.some((reply) => reply.id === data.message.id)) {
|
||||
replies = [...replies, data.message];
|
||||
}
|
||||
selectedThreadState = data.thread_state;
|
||||
}
|
||||
|
||||
async function searchMessages() {
|
||||
if (!selectedWorkspaceID || !searchQuery.trim()) {
|
||||
searchResults = [];
|
||||
return;
|
||||
}
|
||||
const data = await api<{ results: SearchResult[] }>(
|
||||
`/api/search?workspace_id=${encodeURIComponent(selectedWorkspaceID)}&q=${encodeURIComponent(searchQuery.trim())}`
|
||||
);
|
||||
searchResults = data.results;
|
||||
}
|
||||
|
||||
async function uploadFile(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file || !selectedWorkspaceID) return;
|
||||
const form = new FormData();
|
||||
form.set("workspace_id", selectedWorkspaceID);
|
||||
form.set("file", file);
|
||||
const data = await api<{ upload: Upload }>("/api/uploads", { method: "POST", body: form });
|
||||
pendingUpload = data.upload;
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
async function loadDirectConversations() {
|
||||
if (!selectedWorkspaceID) return;
|
||||
const data = await api<{ conversations: DirectConversation[] }>(`/api/dms?workspace_id=${selectedWorkspaceID}`);
|
||||
directConversations = data.conversations;
|
||||
}
|
||||
|
||||
async function createDirectConversation() {
|
||||
if (!selectedWorkspaceID || !directMemberID.trim()) return;
|
||||
const data = await api<{ conversation: DirectConversation }>("/api/dms", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workspace_id: selectedWorkspaceID, member_ids: [directMemberID.trim()] })
|
||||
});
|
||||
directMemberID = "";
|
||||
directConversations = [...directConversations, data.conversation];
|
||||
selectedDirectID = data.conversation.id;
|
||||
selectedChannelID = "";
|
||||
selectedThread = null;
|
||||
await loadMessages();
|
||||
}
|
||||
|
||||
function connectRealtime() {
|
||||
socket?.close();
|
||||
if (!selectedWorkspaceID) return;
|
||||
const lastCursor = localStorage.getItem(`clickclack:${selectedWorkspaceID}:cursor`) || "";
|
||||
const url = new URL("/api/realtime/ws", window.location.href);
|
||||
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
url.searchParams.set("workspace_id", selectedWorkspaceID);
|
||||
if (lastCursor) url.searchParams.set("after_cursor", lastCursor);
|
||||
socket = new WebSocket(url);
|
||||
socket.addEventListener("message", (message) => {
|
||||
const event = JSON.parse(String(message.data)) as RealtimeEvent;
|
||||
if (event.cursor) localStorage.setItem(`clickclack:${selectedWorkspaceID}:cursor`, event.cursor);
|
||||
void handleEvent(event);
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
reconnectTimer = window.setTimeout(connectRealtime, 1200);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEvent(event: RealtimeEvent) {
|
||||
if ((event.type === "channel.created" || event.type === "channel.updated") && event.workspace_id === selectedWorkspaceID) {
|
||||
await loadChannels();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(event.channel_id === selectedChannelID || event.payload.direct_conversation_id === selectedDirectID) &&
|
||||
(event.type === "message.created" || event.type === "message.updated" || event.type === "message.deleted")
|
||||
) {
|
||||
await loadMessages();
|
||||
}
|
||||
const rootID = event.payload.root_message_id || event.payload.message_id;
|
||||
if (selectedThread && rootID === selectedThread.id) {
|
||||
await openThread(selectedThread);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="shell">
|
||||
<aside class="sidebar" aria-label="Workspace and channel navigation">
|
||||
<div class="brand">
|
||||
<div class="mark">cc</div>
|
||||
<div>
|
||||
<strong>ClickClack</strong>
|
||||
<span>{user?.display_name || "local"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="section-title">Workspaces</div>
|
||||
<div class="nav-list">
|
||||
{#each workspaces as workspace}
|
||||
<button
|
||||
class:active={workspace.id === selectedWorkspaceID}
|
||||
onclick={async () => {
|
||||
selectedWorkspaceID = workspace.id;
|
||||
await loadChannels();
|
||||
connectRealtime();
|
||||
}}
|
||||
>
|
||||
{workspace.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<form
|
||||
class="inline-create"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void createWorkspace();
|
||||
}}
|
||||
>
|
||||
<input bind:value={workspaceName} placeholder="New workspace" aria-label="New workspace name" />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">Channels</div>
|
||||
<div class="nav-list channels">
|
||||
{#each channels as channel}
|
||||
<button
|
||||
class:active={channel.id === selectedChannelID}
|
||||
onclick={async () => {
|
||||
selectedChannelID = channel.id;
|
||||
selectedThread = null;
|
||||
await loadMessages();
|
||||
}}
|
||||
>
|
||||
<span>#</span>{channel.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<form
|
||||
class="inline-create"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void createChannel();
|
||||
}}
|
||||
>
|
||||
<input bind:value={channelName} placeholder="New channel" aria-label="New channel name" />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">DMs</div>
|
||||
<div class="nav-list channels">
|
||||
{#each directConversations as conversation}
|
||||
<button
|
||||
class:active={conversation.id === selectedDirectID}
|
||||
onclick={async () => {
|
||||
selectedDirectID = conversation.id;
|
||||
selectedChannelID = "";
|
||||
selectedThread = null;
|
||||
await loadMessages();
|
||||
}}
|
||||
>
|
||||
<span>@</span>{conversation.members.map((member) => member.display_name).join(", ")}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<form
|
||||
class="inline-create"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void createDirectConversation();
|
||||
}}
|
||||
>
|
||||
<input bind:value={directMemberID} placeholder="Member user ID" aria-label="DM member user ID" />
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main class="timeline">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p>{selectedWorkspace?.name || "Workspace"}</p>
|
||||
<h1>{selectedDirect ? "@" + selectedDirect.members.map((member) => member.display_name).join(", ") : "#" + (selectedChannel?.name || "general")}</h1>
|
||||
</div>
|
||||
<form
|
||||
class="search"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void searchMessages();
|
||||
}}
|
||||
>
|
||||
<input bind:value={searchQuery} placeholder="Search" aria-label="Search messages" />
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<div class="connection" data-state={socket?.readyState === WebSocket.OPEN ? "live" : "idle"}>
|
||||
{socket?.readyState === WebSocket.OPEN ? "live" : status}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if searchResults.length > 0}
|
||||
<div class="search-results" aria-label="Search results">
|
||||
{#each searchResults as result (result.message.id)}
|
||||
<button
|
||||
onclick={async () => {
|
||||
searchResults = [];
|
||||
if (result.message.channel_id) {
|
||||
selectedChannelID = result.message.channel_id;
|
||||
selectedDirectID = "";
|
||||
await loadMessages();
|
||||
}
|
||||
if (result.message.direct_conversation_id) {
|
||||
selectedDirectID = result.message.direct_conversation_id;
|
||||
selectedChannelID = "";
|
||||
await loadMessages();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<strong>{result.message.author?.display_name || "Local User"}</strong>
|
||||
<span>{result.message.body}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="messages" aria-live="polite">
|
||||
{#if messages.length === 0}
|
||||
<div class="empty">
|
||||
<strong>Quiet tide.</strong>
|
||||
<span>Start with Markdown. Threads open from any root message.</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each messages as message (message.id)}
|
||||
<article class="message" class:selected={selectedThread?.id === message.id}>
|
||||
<div class="avatar">{message.author?.display_name?.slice(0, 1) || "c"}</div>
|
||||
<div class="message-body">
|
||||
<header>
|
||||
<strong>{message.author?.display_name || "Local User"}</strong>
|
||||
<time>{time(message.created_at)}</time>
|
||||
</header>
|
||||
<div class="markdown">{@html markdown(message.body)}</div>
|
||||
<button class="thread-button" onclick={() => openThread(message)}>Open thread</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="composer"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void sendMessage();
|
||||
}}
|
||||
>
|
||||
<textarea bind:value={messageBody} rows="3" placeholder="Message with Markdown" aria-label="Message body"></textarea>
|
||||
<div class="composer-actions">
|
||||
<label class="upload-button">
|
||||
<input type="file" aria-label="Upload file" onchange={uploadFile} />
|
||||
Upload
|
||||
</label>
|
||||
{#if pendingUpload}
|
||||
<span class="pending-upload">{pendingUpload.filename}</span>
|
||||
{/if}
|
||||
<button type="button" onclick={() => void sendMessage()}>Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<aside class="thread" class:open={selectedThread} aria-label="Thread pane">
|
||||
{#if selectedThread}
|
||||
<header>
|
||||
<div>
|
||||
<p>Thread</p>
|
||||
<strong>{selectedThreadState?.reply_count || replies.length} replies</strong>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close thread"
|
||||
onclick={() => {
|
||||
selectedThread = null;
|
||||
replies = [];
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</header>
|
||||
<article class="thread-root">
|
||||
<strong>{selectedThread.author?.display_name || "Local User"}</strong>
|
||||
<div class="markdown">{@html markdown(selectedThread.body)}</div>
|
||||
</article>
|
||||
<div class="reply-list">
|
||||
{#each replies as reply (reply.id)}
|
||||
<article class="reply">
|
||||
<header>
|
||||
<strong>{reply.author?.display_name || "Local User"}</strong>
|
||||
<time>{time(reply.created_at)}</time>
|
||||
</header>
|
||||
<div class="markdown">{@html markdown(reply.body)}</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
<form
|
||||
class="reply-composer"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void sendReply();
|
||||
}}
|
||||
>
|
||||
<textarea bind:value={replyBody} rows="3" placeholder="Reply in thread" aria-label="Reply body"></textarea>
|
||||
<button type="button" onclick={() => void sendReply()}>Reply</button>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="thread-empty">
|
||||
<strong>No thread open</strong>
|
||||
<span>Pick a message to keep the side conversation tidy.</span>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
136
apps/web/src/ProductSite.svelte
Normal file
136
apps/web/src/ProductSite.svelte
Normal file
@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
const docsURL = "https://docs.clickclack.chat";
|
||||
const appURL = ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname)
|
||||
? "/app"
|
||||
: "https://app.clickclack.chat";
|
||||
const repoURL = "https://github.com/openclaw/clickclack";
|
||||
|
||||
const features = [
|
||||
["Single binary", "Go server, embedded Svelte app, embedded migrations, local SQLite and uploads."],
|
||||
["Threads that recover", "Slack-style one-level threads with durable event replay after reconnects."],
|
||||
["Agent-friendly", "A CLI, OpenAPI contract, TypeScript SDK, webhooks, and slash-command shapes."],
|
||||
["Self-host first", "SQLite is the default, not the demo. Postgres can arrive behind the store layer."],
|
||||
];
|
||||
|
||||
const commands = [
|
||||
"clickclack serve --data ./data",
|
||||
"clickclack login --magic-token mgt_...",
|
||||
"clickclack send --channel general \"deploy started\"",
|
||||
"clickclack threads reply msg_... --stdin <summary.md",
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>ClickClack - Self-hostable chat with claws</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="ClickClack is a self-hostable chat app with Slack-style threads, durable realtime, an agent-friendly CLI, OpenAPI, and a TypeScript SDK."
|
||||
/>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
</svelte:head>
|
||||
|
||||
<main class="product-site">
|
||||
<section class="hero">
|
||||
<div class="hero-bg" aria-hidden="true">
|
||||
<div class="workspace-rail">
|
||||
<span>cc</span>
|
||||
<span>ops</span>
|
||||
<span>bots</span>
|
||||
</div>
|
||||
<div class="timeline-preview">
|
||||
<div class="preview-top">
|
||||
<span># release-room</span>
|
||||
<strong>live</strong>
|
||||
</div>
|
||||
<article>
|
||||
<b>Mira</b>
|
||||
<p>Cut v0.1 once e2e and docs are green.</p>
|
||||
</article>
|
||||
<article>
|
||||
<b>build-bot</b>
|
||||
<p><code>pnpm test:e2e</code> passed. No skipped checks.</p>
|
||||
</article>
|
||||
<article class="thread-line">
|
||||
<b>Peter</b>
|
||||
<p>Thread this with deploy notes and the Hetzner target.</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="thread-preview">
|
||||
<span>Thread</span>
|
||||
<p>One level. No reply-tree archaeology.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="product-nav" aria-label="Product navigation">
|
||||
<a class="brand-lockup" href="/">
|
||||
<span class="brand-mark">cc</span>
|
||||
<span>ClickClack</span>
|
||||
</a>
|
||||
<div>
|
||||
<a href={docsURL}>Docs</a>
|
||||
<a href={appURL}>App</a>
|
||||
<a href={repoURL}>GitHub</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Self-hostable chat. Serious tool. Mild brine.</p>
|
||||
<h1>ClickClack</h1>
|
||||
<p class="lede">
|
||||
A single-binary chat app for teams, communities, bots, and agents:
|
||||
Slack-style threads, durable realtime, OpenAPI, SQLite, and a CLI that
|
||||
can drive the whole thing from a shell.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a class="primary-action" href={appURL}>Open app</a>
|
||||
<a class="secondary-action" href={docsURL}>Read docs</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="product-band intro-band" aria-label="Product summary">
|
||||
<div>
|
||||
<p class="section-kicker">What it is</p>
|
||||
<h2>Chat infrastructure that stays boring when the socket drops.</h2>
|
||||
</div>
|
||||
<p>
|
||||
WebSocket is the pipe. The database is the truth. Every durable message,
|
||||
thread reply, reaction, and channel update can be recovered over HTTP with
|
||||
a cursor, so clients and agents can reconnect without drama.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="feature-grid" aria-label="Feature highlights">
|
||||
{#each features as feature}
|
||||
<article>
|
||||
<h3>{feature[0]}</h3>
|
||||
<p>{feature[1]}</p>
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="product-band cli-band" aria-label="CLI examples">
|
||||
<div>
|
||||
<p class="section-kicker">Agent path</p>
|
||||
<h2>A friendly CLI, no LLM baked in.</h2>
|
||||
<p>
|
||||
External agents, CI jobs, and humans use the same public API as the web
|
||||
app. Tokens and workspace defaults are scoped per server, so switching
|
||||
hosts does not leak credentials or stale IDs.
|
||||
</p>
|
||||
</div>
|
||||
<pre aria-label="CLI command examples">{commands.join("\n")}</pre>
|
||||
</section>
|
||||
|
||||
<section class="product-band docs-band" aria-label="Docs and app destinations">
|
||||
<div>
|
||||
<p class="section-kicker">Destinations</p>
|
||||
<h2>Product at the root. Docs and app where people expect them.</h2>
|
||||
</div>
|
||||
<div class="destination-list">
|
||||
<a href="/">clickclack.chat <span>Product website</span></a>
|
||||
<a href={docsURL}>docs.clickclack.chat <span>Architecture, API, deploy</span></a>
|
||||
<a href={appURL}>app.clickclack.chat <span>Hosted app surface</span></a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@ -1,5 +1,6 @@
|
||||
import { mount } from "svelte";
|
||||
import App from "./App.svelte";
|
||||
import "./product.css";
|
||||
import "./styles.css";
|
||||
|
||||
const app = mount(App, {
|
||||
|
||||
433
apps/web/src/product.css
Normal file
433
apps/web/src/product.css
Normal file
@ -0,0 +1,433 @@
|
||||
.product-site {
|
||||
min-height: 100vh;
|
||||
background: #f7f3ea;
|
||||
color: #151716;
|
||||
font-family:
|
||||
"Avenir Next", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.product-site a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: clamp(680px, 92vh, 920px);
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(12, 31, 35, 0.78), rgba(12, 31, 35, 0.18) 62%, rgba(12, 31, 35, 0.7)),
|
||||
#103033;
|
||||
color: #fff8ee;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: 22vh;
|
||||
min-height: 130px;
|
||||
background: linear-gradient(0deg, #f7f3ea 8%, rgba(247, 243, 234, 0));
|
||||
content: "";
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
position: absolute;
|
||||
inset: 0 38% 0 0;
|
||||
background: linear-gradient(90deg, rgba(8, 28, 30, 0.98), rgba(8, 28, 30, 0));
|
||||
content: "";
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
inset: 0 0 0 clamp(260px, 28vw, 460px);
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(360px, 1fr) minmax(260px, 28vw);
|
||||
gap: 18px;
|
||||
padding: 102px clamp(18px, 5vw, 70px) 80px;
|
||||
opacity: 0.36;
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
.workspace-rail,
|
||||
.timeline-preview,
|
||||
.thread-preview {
|
||||
border: 1px solid rgba(255, 248, 238, 0.24);
|
||||
background: rgba(255, 248, 238, 0.12);
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.24);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.workspace-rail {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.workspace-rail span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 54px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 248, 238, 0.18);
|
||||
color: #fff8ee;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.timeline-preview {
|
||||
align-self: stretch;
|
||||
padding: clamp(18px, 3vw, 34px);
|
||||
}
|
||||
|
||||
.preview-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 48px;
|
||||
color: #b9ddd9;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.preview-top strong {
|
||||
color: #7ce0b5;
|
||||
}
|
||||
|
||||
.timeline-preview article,
|
||||
.thread-preview {
|
||||
max-width: 720px;
|
||||
margin: 0 0 18px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 248, 238, 0.16);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.timeline-preview b,
|
||||
.thread-preview span {
|
||||
color: #ff8a70;
|
||||
}
|
||||
|
||||
.timeline-preview p,
|
||||
.thread-preview p {
|
||||
margin: 6px 0 0;
|
||||
color: #fff8ee;
|
||||
font-size: clamp(17px, 1.8vw, 24px);
|
||||
}
|
||||
|
||||
.timeline-preview code {
|
||||
border-radius: 5px;
|
||||
background: rgba(12, 31, 35, 0.58);
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.thread-line {
|
||||
margin-left: clamp(0px, 8vw, 130px);
|
||||
}
|
||||
|
||||
.thread-preview {
|
||||
align-self: center;
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.product-nav {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 24px clamp(18px, 5vw, 70px);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.product-nav > div {
|
||||
display: flex;
|
||||
gap: clamp(14px, 3vw, 34px);
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: #ff6f55;
|
||||
color: #151716;
|
||||
font-weight: 950;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
align-self: center;
|
||||
width: min(780px, calc(100vw - 36px));
|
||||
padding: 0 clamp(18px, 5vw, 70px) 16vh;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.section-kicker {
|
||||
margin: 0 0 14px;
|
||||
color: #ffb09f;
|
||||
font-size: 13px;
|
||||
font-weight: 950;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1,
|
||||
.product-band h2 {
|
||||
margin: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: clamp(66px, 13vw, 152px);
|
||||
font-weight: 900;
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.lede {
|
||||
width: min(680px, 100%);
|
||||
margin: 26px 0 0;
|
||||
color: #fff3df;
|
||||
font-size: clamp(20px, 2.2vw, 31px);
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
.secondary-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
border-radius: 8px;
|
||||
padding: 0 18px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
background: #ff6f55;
|
||||
color: #151716;
|
||||
}
|
||||
|
||||
.secondary-action {
|
||||
border: 1px solid rgba(255, 248, 238, 0.42);
|
||||
color: #fff8ee;
|
||||
}
|
||||
|
||||
.product-band {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.9fr) minmax(280px, 1.1fr);
|
||||
gap: clamp(28px, 6vw, 90px);
|
||||
padding: clamp(58px, 8vw, 112px) clamp(18px, 5vw, 70px);
|
||||
}
|
||||
|
||||
.product-band h2 {
|
||||
max-width: 720px;
|
||||
font-size: clamp(34px, 5vw, 74px);
|
||||
line-height: 0.96;
|
||||
}
|
||||
|
||||
.product-band p {
|
||||
margin: 0;
|
||||
color: #3f4743;
|
||||
font-size: clamp(18px, 2.1vw, 27px);
|
||||
line-height: 1.34;
|
||||
}
|
||||
|
||||
.intro-band {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.intro-band .section-kicker,
|
||||
.cli-band .section-kicker,
|
||||
.docs-band .section-kicker {
|
||||
color: #b84632;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
border-block: 1px solid #c8c0b2;
|
||||
}
|
||||
|
||||
.feature-grid article {
|
||||
min-height: 250px;
|
||||
border-right: 1px solid #c8c0b2;
|
||||
padding: clamp(22px, 3vw, 40px);
|
||||
}
|
||||
|
||||
.feature-grid article:nth-child(even) {
|
||||
background: #e8f0e9;
|
||||
}
|
||||
|
||||
.feature-grid article:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.feature-grid h3 {
|
||||
margin: 0 0 44px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.feature-grid p {
|
||||
margin: 0;
|
||||
color: #48504c;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.cli-band {
|
||||
background: #162a2d;
|
||||
color: #fff8ee;
|
||||
}
|
||||
|
||||
.cli-band p {
|
||||
color: #c9d8d4;
|
||||
}
|
||||
|
||||
.cli-band pre {
|
||||
overflow: auto;
|
||||
align-self: start;
|
||||
margin: 0;
|
||||
border: 1px solid rgba(255, 248, 238, 0.18);
|
||||
border-radius: 8px;
|
||||
background: #071314;
|
||||
color: #bdf1dc;
|
||||
padding: clamp(18px, 3vw, 32px);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.docs-band {
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.destination-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.destination-list a {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
border-top: 1px solid #cbc2b3;
|
||||
padding: 18px 0;
|
||||
font-size: clamp(21px, 3vw, 40px);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.destination-list span {
|
||||
color: #6f7772;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.product-site {
|
||||
background: #101616;
|
||||
color: #fff8ee;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
background: linear-gradient(0deg, #101616 8%, rgba(16, 22, 22, 0));
|
||||
}
|
||||
|
||||
.product-band p,
|
||||
.feature-grid p,
|
||||
.destination-list span {
|
||||
color: #bac7c3;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
border-color: #34413e;
|
||||
}
|
||||
|
||||
.feature-grid article {
|
||||
border-color: #34413e;
|
||||
}
|
||||
|
||||
.feature-grid article:nth-child(even),
|
||||
.docs-band {
|
||||
background: #182221;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero::before {
|
||||
inset-right: 0;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
inset: 0;
|
||||
grid-template-columns: 58px minmax(0, 1fr);
|
||||
padding-top: 90px;
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.thread-preview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.product-band,
|
||||
.feature-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.feature-grid article,
|
||||
.feature-grid article:last-child {
|
||||
min-height: 0;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #c8c0b2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.product-nav {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero {
|
||||
min-height: 760px;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
grid-template-columns: 1fr;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.workspace-rail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.destination-list a {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.destination-list span {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@ -13,8 +13,9 @@ WebSocket pipe, and a framework-neutral TypeScript SDK so the bots feel at home.
|
||||
It's built for small teams, internal tools, communities, and anyone who would
|
||||
rather host their own.
|
||||
|
||||
Product domain: [clickclack.chat](https://clickclack.chat). Docs domain:
|
||||
[docs.clickclack.chat](https://docs.clickclack.chat).
|
||||
Product domain: [clickclack.chat](https://clickclack.chat). App domain:
|
||||
[app.clickclack.chat](https://app.clickclack.chat), with `/app` as the local
|
||||
path. Docs domain: [docs.clickclack.chat](https://docs.clickclack.chat).
|
||||
|
||||
## Why ClickClack
|
||||
|
||||
@ -40,8 +41,8 @@ go run ./apps/api/cmd/clickclack serve
|
||||
```
|
||||
|
||||
The dev fallback boots a default user, workspace, and channel so the SPA loads
|
||||
into something useful on first hit. Disable it for anything that isn't a local
|
||||
clone.
|
||||
into something useful at `/app`. The root path is the product website. Disable
|
||||
it for anything that isn't a local clone.
|
||||
|
||||
[Get the full quickstart →](quickstart.html)
|
||||
|
||||
|
||||
@ -10,6 +10,13 @@ ClickClack ships as one Go binary that embeds the Svelte SPA and the SQL
|
||||
migrations. The deployment story is "drop a binary on a box, point it at a
|
||||
data directory, run it behind a reverse proxy."
|
||||
|
||||
Public surfaces:
|
||||
|
||||
- `clickclack.chat` — product website.
|
||||
- `app.clickclack.chat` — chat app. The same app is also available at `/app`
|
||||
for local development and simple single-host deployments.
|
||||
- `docs.clickclack.chat` — documentation site built by `pnpm docs:site`.
|
||||
|
||||
## Single binary
|
||||
|
||||
```sh
|
||||
|
||||
@ -17,9 +17,10 @@ pnpm build
|
||||
go run ./apps/api/cmd/clickclack serve
|
||||
```
|
||||
|
||||
Open `http://localhost:8080`. You should see the SPA with a `Local Captain`
|
||||
user already signed in via the dev fallback. That's the empty-dev path —
|
||||
useful for poking around, not what you want long-term.
|
||||
Open `http://localhost:8080` for the product website. Open
|
||||
`http://localhost:8080/app` for the chat app with a `Local Captain` user
|
||||
already signed in via the dev fallback. That's the empty-dev path — useful for
|
||||
poking around, not what you want long-term.
|
||||
|
||||
## 2. Replace the dev user with a real owner
|
||||
|
||||
|
||||
@ -42,6 +42,17 @@ function isolatedHome(): NodeJS.ProcessEnv {
|
||||
};
|
||||
}
|
||||
|
||||
test("product website links to app and docs", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "ClickClack" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Open app" })).toHaveAttribute("href", "/app");
|
||||
await expect(page.getByRole("link", { name: "Read docs" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.clickclack.chat",
|
||||
);
|
||||
await expect(page.getByText("Self-hostable chat. Serious tool. Mild brine.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("sends messages, searches, uploads, opens a thread, and creates a DM", async ({ page }) => {
|
||||
const consoleMessages: string[] = [];
|
||||
page.on("console", (message) => consoleMessages.push(`${message.type()}: ${message.text()}`));
|
||||
@ -69,7 +80,7 @@ test("sends messages, searches, uploads, opens a thread, and creates a DM", asyn
|
||||
{ cwd: process.cwd(), encoding: "utf8" },
|
||||
).trim();
|
||||
|
||||
await page.goto("/");
|
||||
await page.goto("/app");
|
||||
|
||||
await page.getByRole("button", { name: "# general" }).click();
|
||||
await expect(page.getByRole("heading", { name: "#general" })).toBeVisible();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user