inital commit, very early alpha stage

This commit is contained in:
2026-06-30 13:38:24 +02:00
parent f5530ad336
commit 70cf1a84ca
72 changed files with 14074 additions and 2 deletions

8
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
EXPOSE 5173

12
frontend/README.md Normal file
View File

@@ -0,0 +1,12 @@
# GroupHome Frontend
React, Vite, and TypeScript frontend for the GroupHome demo.
```bash
npm install
npm run dev
npm run build
npm run test
```
The dev server proxies `/api` and `/.well-known` to `http://localhost:8000`.

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f7f4ee" />
<title>GroupHome</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3262
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "grouphome-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"test": "vitest run",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"jsdom": "^25.0.0",
"typescript": "^5.5.4",
"vite": "^5.4.2",
"vitest": "^2.0.5"
}
}

View File

@@ -0,0 +1,59 @@
export type ApiError = {
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
};
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
function csrfToken() {
return document.cookie
.split("; ")
.find((part) => part.startsWith("grouphome_csrf="))
?.split("=")[1];
}
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
const isForm = options.body instanceof FormData;
if (!isForm && options.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const csrf = csrfToken();
if (csrf && !headers.has("X-CSRF-Token")) {
headers.set("X-CSRF-Token", decodeURIComponent(csrf));
}
const response = await fetch(`${API_BASE}${path}`, {
credentials: "include",
...options,
headers
});
if (!response.ok) {
let payload: ApiError = {};
try {
payload = await response.json();
} catch {
payload = { error: { code: "request_failed", message: response.statusText } };
}
throw new Error(payload.error?.message ?? "Request failed");
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export function postJson<T>(path: string, body: unknown): Promise<T> {
return api<T>(path, { method: "POST", body: JSON.stringify(body) });
}
export function patchJson<T>(path: string, body: unknown): Promise<T> {
return api<T>(path, { method: "PATCH", body: JSON.stringify(body) });
}
export function deleteJson<T>(path: string): Promise<T> {
return api<T>(path, { method: "DELETE" });
}

114
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,114 @@
export type Group = {
id: string;
name: string;
description: string;
server_origin: string;
legacy_channel_status: string;
transition_deadline?: string | null;
};
export type Member = {
id: string;
group_id: string;
display_name: string;
role: string;
status: string;
};
export type ActionItem = {
id: string;
source_type: "local" | "remote";
source_server_origin: string;
source_group_id: string;
source_group_name: string;
type: string;
priority: string;
title: string;
summary: string;
object_type: string;
object_id: string;
due_at?: string | null;
};
export type EventItem = {
id: string;
group_id: string;
group_name?: string;
source_type?: string;
source_server_origin?: string;
title: string;
description?: string;
starts_at: string;
ends_at?: string | null;
location_name?: string | null;
location_address?: string | null;
rsvp_required: boolean;
rsvp_status?: string | null;
changed_at?: string | null;
};
export type Announcement = {
id: string;
group_id: string;
group_name?: string;
title: string;
body: string;
priority: string;
official: boolean;
created_at: string;
};
export type TaskItem = {
id: string;
group_id: string;
title: string;
description?: string | null;
due_at?: string | null;
status: string;
assigned_to_member_id?: string | null;
};
export type FileAsset = {
id: string;
group_id: string;
group_name?: string;
source_type?: string;
source_server_origin?: string;
filename_original: string;
description?: string | null;
size_bytes: number;
download_url?: string;
created_at: string;
};
export type Poll = {
id: string;
group_id: string;
title: string;
description?: string | null;
status: string;
closes_at?: string | null;
options: { id: string; label: string; position: number; vote_count: number }[];
};
export type Thread = {
id: string;
group_id: string;
group_name?: string;
title: string;
kind: string;
updated_at?: string;
latest_message?: ChatMessage | null;
messages?: ChatMessage[];
};
export type ChatMessage = {
id: string;
thread_id: string;
author_member_id: string;
author_name: string;
body: string;
created_at: string;
mine: boolean;
low_signal: boolean;
};

93
frontend/src/app/App.tsx Normal file
View File

@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { api, postJson } from "../api/client";
import { Loading } from "../components/Loading";
import { Layout } from "./Layout";
import { CalendarPage } from "../routes/CalendarPage";
import { ChatPage } from "../routes/ChatPage";
import { DetailPage } from "../routes/DetailPage";
import { FilesPage } from "../routes/FilesPage";
import { GroupPage } from "../routes/GroupPage";
import { GroupsPage } from "../routes/GroupsPage";
import { HomePage } from "../routes/HomePage";
import { JoinPage } from "../routes/JoinPage";
import { MePage } from "../routes/MePage";
type MeResponse = {
authenticated: boolean;
dev_mode: boolean;
};
export function App() {
const location = useLocation();
const [ready, setReady] = useState(location.pathname.startsWith("/join/"));
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function bootstrap() {
if (location.pathname.startsWith("/join/")) {
setReady(true);
return;
}
try {
const me = await api<MeResponse>("/api/me");
if (!me.authenticated && me.dev_mode) {
await postJson("/api/auth/dev/demo-session", {});
}
if (!cancelled) setReady(true);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Could not open the app.");
setReady(true);
}
}
}
setReady(false);
bootstrap();
return () => {
cancelled = true;
};
}, [location.pathname]);
if (!ready) {
return <Loading label="Opening GroupHome" />;
}
if (error) {
return (
<main className="standalone">
<div className="error-panel">
<h1>GroupHome</h1>
<p>{error}</p>
<p>Run the backend seed command, then refresh this page.</p>
</div>
</main>
);
}
return (
<Routes>
<Route path="/join/:token" element={<JoinPage />} />
<Route element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/groups/:groupId" element={<GroupPage />} />
<Route path="/groups/:groupId/admin" element={<GroupPage initialTab="admin" />} />
<Route path="/groups/:groupId/discussions" element={<GroupPage initialTab="discussions" />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/me" element={<MePage />} />
<Route path="/me/devices" element={<MePage initialPanel="devices" />} />
<Route path="/me/notifications" element={<MePage initialPanel="notifications" />} />
<Route path="/me/servers/connect" element={<MePage initialPanel="servers" />} />
<Route path="/events/:id" element={<DetailPage kind="events" />} />
<Route path="/announcements/:id" element={<DetailPage kind="announcements" />} />
<Route path="/tasks/:id" element={<DetailPage kind="tasks" />} />
<Route path="/polls/:id" element={<DetailPage kind="polls" />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
);
}

View File

@@ -0,0 +1,46 @@
import { CalendarDays, Folder, Home, MessageCircle, User, UsersRound } from "lucide-react";
import { NavLink, Outlet } from "react-router-dom";
const nav = [
{ to: "/", label: "Home", icon: Home },
{ to: "/calendar", label: "Calendar", icon: CalendarDays },
{ to: "/chat", label: "Chat", icon: MessageCircle, primary: true },
{ to: "/groups", label: "Groups", icon: UsersRound },
{ to: "/files", label: "Files", icon: Folder },
{ to: "/me", label: "Me", icon: User }
];
export function Layout() {
return (
<div className="app-shell">
<aside className="side-nav" aria-label="Main navigation">
<div className="brand-lockup">
<span className="brand-mark">GH</span>
<div>
<strong>GroupHome</strong>
<small>Command center</small>
</div>
</div>
<nav>
{nav.map((item) => (
<NavLink key={item.to} to={item.to} end={item.to === "/"} className={({ isActive }) => `${item.primary ? "nav-item primary" : "nav-item"}${isActive ? " active" : ""}`}>
<item.icon size={19} aria-hidden />
<span>{item.label}</span>
</NavLink>
))}
</nav>
</aside>
<main className="main-panel">
<Outlet />
</main>
<nav className="bottom-nav" aria-label="Main navigation">
{nav.map((item) => (
<NavLink key={item.to} to={item.to} end={item.to === "/"} className={({ isActive }) => `${item.primary ? "bottom-item primary" : "bottom-item"}${isActive ? " active" : ""}`}>
<item.icon size={20} aria-hidden />
<span>{item.label}</span>
</NavLink>
))}
</nav>
</div>
);
}

View File

@@ -0,0 +1,25 @@
export function formatDateTime(value?: string | null) {
if (!value) return "";
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
}
export function formatDate(value?: string | null) {
if (!value) return "";
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric"
}).format(new Date(value));
}
export function fileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
type BadgeProps = {
children: ReactNode;
tone?: "neutral" | "urgent" | "good" | "remote" | "quiet";
};
export function Badge({ children, tone = "neutral" }: BadgeProps) {
return <span className={`badge badge-${tone}`}>{children}</span>;
}

View File

@@ -0,0 +1,10 @@
import type { ButtonHTMLAttributes } from "react";
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost" | "danger";
};
export function Button({ variant = "primary", className = "", ...props }: ButtonProps) {
return <button className={`button button-${variant} ${className}`} {...props} />;
}

View File

@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
type CardProps = {
children: ReactNode;
className?: string;
};
export function Card({ children, className = "" }: CardProps) {
return <article className={`card ${className}`}>{children}</article>;
}

View File

@@ -0,0 +1,12 @@
import { Inbox } from "lucide-react";
export function EmptyState({ title, body }: { title: string; body?: string }) {
return (
<div className="empty-state">
<Inbox size={22} aria-hidden />
<strong>{title}</strong>
{body ? <p>{body}</p> : null}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "react";
type Props = {
label: string;
children: ReactNode;
};
export function FormRow({ label, children }: Props) {
return (
<label className="form-row">
<span>{label}</span>
{children}
</label>
);
}
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
return <input className="input" {...props} />;
}
export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea className="input textarea" {...props} />;
}

View File

@@ -0,0 +1,9 @@
export function Loading({ label = "Loading" }: { label?: string }) {
return (
<div className="loading" role="status" aria-live="polite">
<span className="skeleton skeleton-dot" />
<span>{label}</span>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
type SectionProps = {
title: string;
eyebrow?: string;
action?: ReactNode;
children: ReactNode;
};
export function Section({ title, eyebrow, action, children }: SectionProps) {
return (
<section className="section">
<div className="section-heading">
<div>
{eyebrow ? <span className="eyebrow">{eyebrow}</span> : null}
<h2>{title}</h2>
</div>
{action}
</div>
{children}
</section>
);
}

14
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./app/App";
import "./styles/base.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,54 @@
import { MapPin } from "lucide-react";
import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { EventItem } from "../api/types";
import { formatDateTime } from "../app/format";
import { Badge } from "../components/Badge";
import { Card } from "../components/Card";
import { EmptyState } from "../components/EmptyState";
import { Loading } from "../components/Loading";
import { Section } from "../components/Section";
export function CalendarPage() {
const [events, setEvents] = useState<EventItem[] | null>(null);
useEffect(() => {
api<{ events: EventItem[] }>("/api/home/calendar").then((data) => setEvents(data.events));
}, []);
if (!events) return <Loading label="Loading calendar" />;
return (
<div className="page-stack">
<header className="page-header">
<span className="eyebrow">Calendar</span>
<h1>Upcoming across groups</h1>
<p>Events from local memberships and connected group servers.</p>
</header>
<Section title="Agenda">
<div className="timeline">
{events.length === 0 ? <EmptyState title="No events scheduled" /> : null}
{events.map((event) => (
<Card key={`${event.source_type}-${event.id}`} className="timeline-card">
<div className="date-block">{formatDateTime(event.starts_at)}</div>
<div>
<div className="card-topline">
<Badge tone={event.source_type === "remote" ? "remote" : "neutral"}>{event.group_name}</Badge>
{event.changed_at ? <Badge tone="urgent">Changed</Badge> : null}
</div>
<h3>{event.title}</h3>
{event.location_name ? (
<p className="inline-meta">
<MapPin size={15} /> {event.location_name}
</p>
) : null}
{event.rsvp_required ? <p>RSVP: {event.rsvp_status ?? "unknown"}</p> : null}
</div>
</Card>
))}
</div>
</Section>
</div>
);
}

View File

@@ -0,0 +1,298 @@
import { CalendarPlus, CheckSquare, ChevronDown, Link2, Megaphone, MessageCircle, Plus, Send, Vote } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { api, postJson } from "../api/client";
import type { ChatMessage, Group, Member, Thread } from "../api/types";
import { formatDateTime } from "../app/format";
import { Badge } from "../components/Badge";
import { Button } from "../components/Button";
import { FormRow, TextInput } from "../components/FormRow";
import { Loading } from "../components/Loading";
type ChatResponse = {
groups: { group: Group; member: Member }[];
active_group: Group | null;
active_member: Member | null;
members: Member[];
threads: Thread[];
active_thread: Thread | null;
current_member_id: string | null;
};
type StructureKind = "message" | "poll" | "event" | "task" | "announcement" | "invite" | "feedback";
const structureOptions: { kind: StructureKind; label: string; icon: typeof MessageCircle }[] = [
{ kind: "message", label: "Message", icon: MessageCircle },
{ kind: "poll", label: "Poll", icon: Vote },
{ kind: "event", label: "Event", icon: CalendarPlus },
{ kind: "task", label: "Task", icon: CheckSquare },
{ kind: "announcement", label: "Announcement", icon: Megaphone },
{ kind: "invite", label: "Invite", icon: Link2 },
{ kind: "feedback", label: "Feedback", icon: MessageCircle }
];
function titleFromDraft(draft: string) {
const firstLine = draft.trim().split("\n").find(Boolean) || "New item";
return firstLine.replace(/^[-*]\s*/, "").slice(0, 140);
}
function pollOptionsFromDraft(draft: string) {
const lines = draft
.split("\n")
.map((line) => line.trim().replace(/^[-*]\s*/, ""))
.filter(Boolean);
const optionLines = lines.slice(1).filter((line) => line.length <= 80);
if (optionLines.length >= 2) return optionLines.slice(0, 8).map((label) => ({ label }));
if (draft.includes(",")) {
const parts = draft
.split(":")
.pop()
?.split(",")
.map((part) => part.trim())
.filter(Boolean);
if (parts && parts.length >= 2) return parts.slice(0, 8).map((label) => ({ label }));
}
return [{ label: "Yes" }, { label: "No" }];
}
function guessedKind(draft: string): StructureKind {
const text = draft.toLowerCase();
if (!text.trim()) return "message";
if (/(invite|join link|new people|qr)/.test(text)) return "invite";
if (/(poll|vote|choose|which option|yes\/no)/.test(text)) return "poll";
if (/(training|match|meeting|appointment|event|tomorrow|tonight|rescheduled|moved|changed)/.test(text)) return "event";
if (/(todo|task|can someone|please bring|need someone|driver|bring)/.test(text)) return "task";
if (/(announce|official|important|urgent|from now)/.test(text)) return "announcement";
if (/(feedback|idea|concern|issue)/.test(text)) return "feedback";
return "message";
}
function defaultStartsAt(draft: string) {
const lower = draft.toLowerCase();
const date = new Date();
if (lower.includes("tomorrow")) {
date.setDate(date.getDate() + 1);
} else {
date.setDate(date.getDate() + 2);
}
date.setHours(lower.includes("morning") ? 9 : lower.includes("afternoon") ? 15 : 18, 0, 0, 0);
return date.toISOString();
}
function displayMessages(messages: ChatMessage[], foldNoise: boolean) {
if (!foldNoise) return messages.map((message) => ({ type: "message" as const, message }));
const items: ({ type: "message"; message: ChatMessage } | { type: "fold"; count: number; names: string[] })[] = [];
let folded: ChatMessage[] = [];
const flush = () => {
if (folded.length) {
items.push({ type: "fold", count: folded.length, names: [...new Set(folded.map((message) => message.author_name))].slice(0, 3) });
folded = [];
}
};
for (const message of messages) {
if (message.low_signal && !message.mine) {
folded.push(message);
} else {
flush();
items.push({ type: "message", message });
}
}
flush();
return items;
}
export function ChatPage() {
const [data, setData] = useState<ChatResponse | null>(null);
const [groupId, setGroupId] = useState<string>("");
const [threadId, setThreadId] = useState<string>("");
const [draft, setDraft] = useState("");
const [selectedKind, setSelectedKind] = useState<StructureKind>("message");
const [newThreadTitle, setNewThreadTitle] = useState("");
const [notice, setNotice] = useState("");
const [foldNoise, setFoldNoise] = useState(() => localStorage.getItem("grouphome.foldNoise") === "true");
const suggestedKind = useMemo(() => guessedKind(draft), [draft]);
const activeGroupId = data?.active_group?.id ?? groupId;
const activeThreadId = data?.active_thread?.id ?? threadId;
async function load(nextGroupId = groupId, nextThreadId = threadId) {
const params = new URLSearchParams();
if (nextGroupId) params.set("group_id", nextGroupId);
if (nextThreadId) params.set("thread_id", nextThreadId);
const result = await api<ChatResponse>(`/api/chat${params.toString() ? `?${params.toString()}` : ""}`);
setData(result);
setGroupId(result.active_group?.id ?? "");
setThreadId(result.active_thread?.id ?? "");
}
useEffect(() => {
load();
}, []);
useEffect(() => {
localStorage.setItem("grouphome.foldNoise", String(foldNoise));
}, [foldNoise]);
async function chooseGroup(nextGroupId: string) {
setGroupId(nextGroupId);
setThreadId("");
await load(nextGroupId, "");
}
async function chooseThread(nextThreadId: string) {
setThreadId(nextThreadId);
await load(groupId, nextThreadId);
}
async function createThread(event: FormEvent) {
event.preventDefault();
if (!activeGroupId || !newThreadTitle.trim()) return;
const result = await postJson<{ thread: Thread }>(`/api/chat/threads?group_id=${activeGroupId}`, { title: newThreadTitle, kind: "discussion" });
setNewThreadTitle("");
await load(activeGroupId, result.thread.id);
}
async function sendMessage(body: string) {
await postJson(`/api/chat/threads/${activeThreadId}/messages`, { body });
}
async function submit(event: FormEvent) {
event.preventDefault();
if (!draft.trim() || !activeGroupId || !activeThreadId) return;
const kind = selectedKind;
const title = titleFromDraft(draft);
try {
if (kind === "message") {
await sendMessage(draft);
} else if (kind === "poll") {
await postJson(`/api/groups/${activeGroupId}/polls`, { title, description: draft, options: pollOptionsFromDraft(draft) });
await sendMessage(`Created poll: ${title}`);
} else if (kind === "event") {
await postJson(`/api/groups/${activeGroupId}/events`, { title, description: draft, starts_at: defaultStartsAt(draft), rsvp_required: true, location_name: "TBD" });
await sendMessage(`Created event: ${title}`);
} else if (kind === "task") {
await postJson(`/api/groups/${activeGroupId}/tasks`, { title, description: draft });
await sendMessage(`Created task: ${title}`);
} else if (kind === "announcement") {
const canPostOfficial = ["owner", "admin", "moderator"].includes(data?.active_member?.role ?? "");
await postJson(`/api/groups/${activeGroupId}/announcements`, { title, body: draft, official: canPostOfficial, priority: /urgent|important/i.test(draft) ? "urgent" : "normal" });
await sendMessage(`${canPostOfficial ? "Posted official announcement" : "Posted announcement"}: ${title}`);
} else if (kind === "invite") {
const invite = await postJson<{ invite_url: string }>(`/api/groups/${activeGroupId}/invites`, { label: title, max_uses: 50 });
await sendMessage(`Invite link: ${invite.invite_url}`);
} else if (kind === "feedback") {
await sendMessage(`Feedback: ${draft}`);
}
setDraft("");
setSelectedKind("message");
setNotice("");
await load(activeGroupId, activeThreadId);
} catch (err) {
setNotice(err instanceof Error ? err.message : "Could not create that item.");
}
}
if (!data) return <Loading label="Opening chat" />;
if (!data.active_group || !data.active_thread) {
return <div className="error-panel">Join a group to start chatting.</div>;
}
const messages = data.active_thread.messages ?? [];
const renderedMessages = displayMessages(messages, foldNoise);
return (
<div className="chat-page">
<aside className="chat-sidebar">
<div className="chat-sidebar-header">
<div>
<span className="eyebrow">Chat</span>
<h1>Messages</h1>
</div>
<Badge tone="good">{data.groups.length} groups</Badge>
</div>
<FormRow label="Group">
<select className="input" value={data.active_group.id} onChange={(event) => chooseGroup(event.target.value)}>
{data.groups.map(({ group }) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</FormRow>
<form className="inline-form" onSubmit={createThread}>
<TextInput value={newThreadTitle} onChange={(event) => setNewThreadTitle(event.target.value)} placeholder="New thread" />
<Button type="submit" variant="secondary" aria-label="Create thread">
<Plus size={16} />
</Button>
</form>
<div className="chat-thread-list">
{data.threads.map((thread) => (
<button key={thread.id} type="button" className={thread.id === data.active_thread?.id ? "chat-thread active" : "chat-thread"} onClick={() => chooseThread(thread.id)}>
<strong>{thread.title}</strong>
<span>{thread.latest_message?.body ?? "No messages yet"}</span>
<small>{thread.latest_message?.created_at ? formatDateTime(thread.latest_message.created_at) : thread.kind}</small>
</button>
))}
</div>
</aside>
<section className="chat-panel">
<header className="chat-header">
<div>
<span className="eyebrow">{data.active_group.name}</span>
<h2>{data.active_thread.title}</h2>
</div>
<label className="toggle-row">
<input type="checkbox" checked={foldNoise} onChange={(event) => setFoldNoise(event.target.checked)} />
<span>Fold short replies</span>
</label>
</header>
<div className="message-list">
{renderedMessages.map((item, index) =>
item.type === "fold" ? (
<div className="folded-noise" key={`fold-${index}`}>
<ChevronDown size={15} />
{item.count} short replies folded from {item.names.join(", ")}
</div>
) : (
<div key={item.message.id} className={item.message.mine ? "message-row mine" : "message-row"}>
<div className={item.message.mine ? "bubble mine" : "bubble"}>
<div className="bubble-meta">
<strong>{item.message.author_name}</strong>
<span>{formatDateTime(item.message.created_at)}</span>
</div>
<p>{item.message.body}</p>
</div>
</div>
)
)}
</div>
<form className="chat-composer" onSubmit={submit}>
{notice ? <div className="notice">{notice}</div> : null}
<div className="structure-strip">
{structureOptions.map((option) => {
const Icon = option.icon;
const active = selectedKind === option.kind;
return (
<button key={option.kind} type="button" className={active ? "structure-chip active" : "structure-chip"} onClick={() => setSelectedKind(option.kind)}>
<Icon size={15} />
{option.label}
</button>
);
})}
</div>
{suggestedKind !== "message" && selectedKind === "message" ? (
<div className="composer-hint">Looks like a {suggestedKind}. Send normally or create the structured item.</div>
) : null}
<div className="composer-row">
<textarea className="input chat-input" value={draft} onChange={(event) => setDraft(event.target.value)} placeholder="Write a message, poll, event, task, invite, or feedback..." />
<Button type="submit" aria-label="Send">
<Send size={18} />
</Button>
</div>
</form>
</section>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { Check, Server } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { api, patchJson, postJson } from "../api/client";
import { formatDateTime } from "../app/format";
import { Badge } from "../components/Badge";
import { Button } from "../components/Button";
import { Card } from "../components/Card";
import { Loading } from "../components/Loading";
import { Section } from "../components/Section";
export function DetailPage({ kind }: { kind: "events" | "announcements" | "tasks" | "polls" }) {
const { id } = useParams();
const [item, setItem] = useState<any | null>(null);
async function load() {
if (!id) return;
const data = await api<any>(`/api/${kind}/${id}`);
const key = kind === "events" ? "event" : kind === "announcements" ? "announcement" : kind === "tasks" ? "task" : "poll";
setItem(data[key]);
}
useEffect(() => {
load();
}, [id, kind]);
if (!item) return <Loading label="Loading detail" />;
async function rsvp(status: "yes" | "maybe" | "no") {
await postJson(`/api/events/${item.id}/rsvp`, { status });
await load();
}
async function completeTask() {
await patchJson(`/api/tasks/${item.id}`, { status: "done" });
await load();
}
async function vote(optionId: string) {
await postJson(`/api/polls/${item.id}/vote`, { option_id: optionId });
await load();
}
return (
<div className="page-stack">
<header className="page-header">
<span className="eyebrow">{kind}</span>
<h1>{item.title}</h1>
<p>{item.description || item.body || item.group_name}</p>
</header>
<Section title="Details">
<Card>
<div className="card-topline">
{item.group_name ? <Badge tone="neutral">{item.group_name}</Badge> : null}
{item.source_type === "remote" ? (
<Badge tone="remote">
<Server size={13} /> Remote
</Badge>
) : null}
{item.priority ? <Badge tone={item.priority === "urgent" ? "urgent" : "good"}>{item.priority}</Badge> : null}
</div>
{item.starts_at ? <p>{formatDateTime(item.starts_at)}</p> : null}
{item.due_at ? <p>Due {formatDateTime(item.due_at)}</p> : null}
{item.location_name ? <p>{item.location_name}</p> : null}
{kind === "events" ? (
<div className="button-row">
<Button type="button" onClick={() => rsvp("yes")}>
Yes
</Button>
<Button type="button" variant="secondary" onClick={() => rsvp("maybe")}>
Maybe
</Button>
<Button type="button" variant="ghost" onClick={() => rsvp("no")}>
No
</Button>
</div>
) : null}
{kind === "tasks" && item.status === "open" ? (
<Button type="button" onClick={completeTask}>
<Check size={16} /> Complete task
</Button>
) : null}
{kind === "polls" ? (
<div className="stack-tight">
{item.options.map((option: any) => (
<Button key={option.id} type="button" variant="secondary" onClick={() => vote(option.id)}>
{option.label} · {option.vote_count}
</Button>
))}
</div>
) : null}
{item.group_id ? (
<Link className="text-link" to={`/groups/${item.group_id}`}>
Back to group
</Link>
) : null}
</Card>
</Section>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { Download, Upload } from "lucide-react";
import { FormEvent, useEffect, useState } from "react";
import { api } from "../api/client";
import type { FileAsset, Group, Member } from "../api/types";
import { fileSize } from "../app/format";
import { Badge } from "../components/Badge";
import { Button } from "../components/Button";
import { Card } from "../components/Card";
import { FormRow, TextInput } from "../components/FormRow";
import { Loading } from "../components/Loading";
import { Section } from "../components/Section";
export function FilesPage() {
const [files, setFiles] = useState<FileAsset[] | null>(null);
const [groups, setGroups] = useState<{ group: Group; member: Member }[]>([]);
const [groupId, setGroupId] = useState("");
const [upload, setUpload] = useState<File | null>(null);
const [description, setDescription] = useState("");
async function load() {
const [fileData, groupData] = await Promise.all([api<{ files: FileAsset[] }>("/api/home/files"), api<{ groups: { group: Group; member: Member }[] }>("/api/groups")]);
setFiles(fileData.files);
setGroups(groupData.groups);
setGroupId((current) => current || groupData.groups[0]?.group.id || "");
}
useEffect(() => {
load();
}, []);
async function submit(event: FormEvent) {
event.preventDefault();
if (!upload || !groupId) return;
const form = new FormData();
form.append("upload", upload);
form.append("description", description);
await api(`/api/groups/${groupId}/files`, { method: "POST", body: form });
setUpload(null);
setDescription("");
await load();
}
if (!files) return <Loading label="Loading files" />;
return (
<div className="page-stack">
<header className="page-header">
<span className="eyebrow">Files</span>
<h1>Shared documents</h1>
<p>Global file list with source group and server visible.</p>
</header>
<Section title="Upload local file">
<Card>
<form className="form-grid" onSubmit={submit}>
<FormRow label="Group">
<select className="input" value={groupId} onChange={(event) => setGroupId(event.target.value)}>
{groups.map((item) => (
<option key={item.group.id} value={item.group.id}>
{item.group.name}
</option>
))}
</select>
</FormRow>
<FormRow label="Description">
<TextInput value={description} onChange={(event) => setDescription(event.target.value)} placeholder="What members should know" />
</FormRow>
<FormRow label="File">
<input className="input" type="file" onChange={(event) => setUpload(event.target.files?.[0] ?? null)} />
</FormRow>
<Button type="submit" disabled={!upload || !groupId}>
<Upload size={16} /> Upload
</Button>
</form>
</Card>
</Section>
<Section title="All files">
<div className="list-panel">
{files.map((file) => (
<a className="list-row" href={file.download_url ?? "#"} key={`${file.source_type}-${file.id}`}>
<div>
<strong>{file.filename_original}</strong>
<span>{file.description || file.group_name}</span>
</div>
<div className="row-tail">
<Badge tone={file.source_type === "remote" ? "remote" : "neutral"}>{file.group_name}</Badge>
<span>{fileSize(file.size_bytes)}</span>
<Download size={16} />
</div>
</a>
))}
</div>
</Section>
</div>
);
}

View File

@@ -0,0 +1,473 @@
import { Bell, CalendarPlus, Check, Clipboard, FileUp, MessageSquare, Plus, Vote } from "lucide-react";
import { FormEvent, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { api, patchJson, postJson } from "../api/client";
import type { ActionItem, Announcement, EventItem, FileAsset, Group, Member, Poll, TaskItem, Thread } from "../api/types";
import { formatDateTime } from "../app/format";
import { Badge } from "../components/Badge";
import { Button } from "../components/Button";
import { Card } from "../components/Card";
import { EmptyState } from "../components/EmptyState";
import { FormRow, TextArea, TextInput } from "../components/FormRow";
import { Loading } from "../components/Loading";
import { Section } from "../components/Section";
type GroupResponse = {
group: Group;
member: Member;
members: Member[];
dashboard: {
important_now: ActionItem[];
upcoming: EventItem[];
open_actions: ActionItem[];
announcements: Announcement[];
tasks: TaskItem[];
polls: Poll[];
files: FileAsset[];
discussions: Thread[];
};
};
type Props = {
initialTab?: "dashboard" | "compose" | "discussions" | "admin";
};
export function GroupPage({ initialTab = "dashboard" }: Props) {
const { groupId } = useParams();
const [data, setData] = useState<GroupResponse | null>(null);
const [tab, setTab] = useState(initialTab);
const [notice, setNotice] = useState("");
async function load() {
if (!groupId) return;
setData(await api<GroupResponse>(`/api/groups/${groupId}`));
}
useEffect(() => {
setTab(initialTab);
}, [initialTab]);
useEffect(() => {
load();
}, [groupId]);
if (!data) return <Loading label="Loading group" />;
const { group, member, dashboard, members } = data;
const canAdmin = ["owner", "admin"].includes(member.role);
const canModerate = ["owner", "admin", "moderator"].includes(member.role);
async function rsvp(eventId: string, status: "yes" | "maybe" | "no") {
await postJson(`/api/events/${eventId}/rsvp`, { status });
await load();
}
async function completeTask(taskId: string) {
await patchJson(`/api/tasks/${taskId}`, { status: "done" });
await load();
}
async function vote(pollId: string, optionId: string) {
await postJson(`/api/polls/${pollId}/vote`, { option_id: optionId });
await load();
}
return (
<div className="page-stack">
<header className="page-header">
<span className="eyebrow">Group</span>
<h1>{group.name}</h1>
<p>{group.description}</p>
<div className="button-row">
<Badge tone="good">{member.role}</Badge>
<Badge tone="quiet">{group.legacy_channel_status}</Badge>
{group.transition_deadline ? <Badge tone="urgent">Transition {group.transition_deadline}</Badge> : null}
</div>
</header>
<div className="segmented" role="tablist">
{["dashboard", "compose", "discussions", "admin"].map((item) => (
<button key={item} type="button" className={tab === item ? "active" : ""} onClick={() => setTab(item as typeof tab)} disabled={item === "admin" && !canAdmin}>
{item}
</button>
))}
</div>
{notice ? <div className="notice">{notice}</div> : null}
{tab === "dashboard" ? (
<>
<Section title="Important now">
<div className="card-grid">
{dashboard.important_now.length === 0 ? <EmptyState title="Nothing urgent" /> : null}
{dashboard.important_now.map((action) => (
<Card key={action.id}>
<Badge tone={action.priority === "urgent" ? "urgent" : "neutral"}>{action.type.replaceAll("_", " ")}</Badge>
<h3>{action.title}</h3>
<p>{action.summary}</p>
{action.object_type === "event" ? (
<div className="button-row">
<Button type="button" onClick={() => rsvp(action.object_id, "yes")}>
<Check size={16} /> Yes
</Button>
<Button type="button" variant="secondary" onClick={() => rsvp(action.object_id, "maybe")}>
Maybe
</Button>
</div>
) : (
<Link className="text-link" to={`/${action.object_type}s/${action.object_id}`}>
Open
</Link>
)}
</Card>
))}
</div>
</Section>
<Section title="Upcoming events">
<div className="list-panel">
{dashboard.upcoming.map((event) => (
<div className="list-row" key={event.id}>
<div>
<strong>{event.title}</strong>
<span>{event.location_name || event.description}</span>
</div>
<div className="row-tail">
<span>{formatDateTime(event.starts_at)}</span>
{event.rsvp_required ? <Badge tone={event.rsvp_status === "yes" ? "good" : "urgent"}>RSVP {event.rsvp_status}</Badge> : null}
</div>
{event.rsvp_required ? (
<div className="button-row compact">
<Button type="button" variant="secondary" onClick={() => rsvp(event.id, "yes")}>
Yes
</Button>
<Button type="button" variant="ghost" onClick={() => rsvp(event.id, "no")}>
No
</Button>
</div>
) : null}
</div>
))}
</div>
</Section>
<div className="two-column">
<Section title="Official announcements">
<div className="list-panel">
{dashboard.announcements.map((item) => (
<Link className="list-row" to={`/announcements/${item.id}`} key={item.id}>
<div>
<strong>{item.title}</strong>
<span>{item.body}</span>
</div>
<Badge tone={item.priority === "urgent" ? "urgent" : "good"}>{item.official ? "Official" : "Post"}</Badge>
</Link>
))}
</div>
</Section>
<Section title="Files">
<div className="list-panel">
{dashboard.files.map((file) => (
<a href={file.download_url} className="list-row" key={file.id}>
<strong>{file.filename_original}</strong>
<span>{file.description}</span>
</a>
))}
</div>
</Section>
</div>
<Section title="Tasks and polls">
<div className="card-grid">
{dashboard.tasks.map((task) => (
<Card key={task.id}>
<Badge tone={task.status === "open" ? "urgent" : "good"}>{task.status}</Badge>
<h3>{task.title}</h3>
<p>{task.description}</p>
{task.due_at ? <p>{formatDateTime(task.due_at)}</p> : null}
{task.status === "open" ? (
<Button type="button" variant="secondary" onClick={() => completeTask(task.id)}>
Complete
</Button>
) : null}
</Card>
))}
{dashboard.polls.map((poll) => (
<Card key={poll.id}>
<Badge tone="neutral">Poll</Badge>
<h3>{poll.title}</h3>
<p>{poll.description}</p>
<div className="stack-tight">
{poll.options.map((option) => (
<Button key={option.id} type="button" variant="secondary" onClick={() => vote(poll.id, option.id)}>
{option.label} · {option.vote_count}
</Button>
))}
</div>
</Card>
))}
</div>
</Section>
</>
) : null}
{tab === "compose" ? (
<ComposePanel group={group} members={members} canModerate={canModerate} onDone={load} setNotice={setNotice} />
) : null}
{tab === "discussions" ? (
<DiscussionsPanel group={group} threads={dashboard.discussions} onDone={load} />
) : null}
{tab === "admin" && canAdmin ? <AdminPanel group={group} onDone={load} setNotice={setNotice} /> : null}
</div>
);
}
function ComposePanel({
group,
members,
canModerate,
onDone,
setNotice
}: {
group: Group;
members: Member[];
canModerate: boolean;
onDone: () => Promise<void>;
setNotice: (value: string) => void;
}) {
const [announcementTitle, setAnnouncementTitle] = useState("");
const [announcementBody, setAnnouncementBody] = useState("");
const [eventTitle, setEventTitle] = useState("");
const [taskTitle, setTaskTitle] = useState("");
const [assignedTo, setAssignedTo] = useState("");
const [pollTitle, setPollTitle] = useState("");
const [file, setFile] = useState<File | null>(null);
async function submitAnnouncement(event: FormEvent) {
event.preventDefault();
await postJson(`/api/groups/${group.id}/announcements`, { title: announcementTitle, body: announcementBody, priority: "normal", official: canModerate, requires_ack: false });
setAnnouncementTitle("");
setAnnouncementBody("");
setNotice("Announcement created.");
await onDone();
}
async function submitEvent(event: FormEvent) {
event.preventDefault();
const startsAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString();
await postJson(`/api/groups/${group.id}/events`, { title: eventTitle, starts_at: startsAt, rsvp_required: true, location_name: "TBD" });
setEventTitle("");
setNotice("Event created.");
await onDone();
}
async function submitTask(event: FormEvent) {
event.preventDefault();
await postJson(`/api/groups/${group.id}/tasks`, { title: taskTitle, assigned_to_member_id: assignedTo || null });
setTaskTitle("");
setNotice("Task created.");
await onDone();
}
async function submitPoll(event: FormEvent) {
event.preventDefault();
await postJson(`/api/groups/${group.id}/polls`, { title: pollTitle, options: [{ label: "Yes" }, { label: "No" }] });
setPollTitle("");
setNotice("Poll created.");
await onDone();
}
async function submitFile(event: FormEvent) {
event.preventDefault();
if (!file) return;
const form = new FormData();
form.append("upload", file);
form.append("description", "Uploaded from group dashboard");
await api(`/api/groups/${group.id}/files`, { method: "POST", body: form });
setFile(null);
setNotice("File uploaded.");
await onDone();
}
return (
<Section title="Create structured content" eyebrow="Composer">
<div className="card-grid">
<Card>
<form className="form-grid" onSubmit={submitAnnouncement}>
<h3>
<Bell size={17} /> Announcement
</h3>
<FormRow label="Title">
<TextInput value={announcementTitle} onChange={(event) => setAnnouncementTitle(event.target.value)} required />
</FormRow>
<FormRow label="Body">
<TextArea value={announcementBody} onChange={(event) => setAnnouncementBody(event.target.value)} />
</FormRow>
<Button type="submit">Post</Button>
</form>
</Card>
<Card>
<form className="form-grid" onSubmit={submitEvent}>
<h3>
<CalendarPlus size={17} /> Event
</h3>
<FormRow label="Title">
<TextInput value={eventTitle} onChange={(event) => setEventTitle(event.target.value)} required />
</FormRow>
<Button type="submit">Create event</Button>
</form>
</Card>
<Card>
<form className="form-grid" onSubmit={submitTask}>
<h3>
<Check size={17} /> Task
</h3>
<FormRow label="Title">
<TextInput value={taskTitle} onChange={(event) => setTaskTitle(event.target.value)} required />
</FormRow>
<FormRow label="Assigned to">
<select className="input" value={assignedTo} onChange={(event) => setAssignedTo(event.target.value)}>
<option value="">No one yet</option>
{members.map((item) => (
<option value={item.id} key={item.id}>
{item.display_name}
</option>
))}
</select>
</FormRow>
<Button type="submit">Create task</Button>
</form>
</Card>
<Card>
<form className="form-grid" onSubmit={submitPoll}>
<h3>
<Vote size={17} /> Poll
</h3>
<FormRow label="Question">
<TextInput value={pollTitle} onChange={(event) => setPollTitle(event.target.value)} required />
</FormRow>
<Button type="submit">Create yes/no poll</Button>
</form>
</Card>
<Card>
<form className="form-grid" onSubmit={submitFile}>
<h3>
<FileUp size={17} /> File
</h3>
<FormRow label="Upload">
<input className="input" type="file" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
</FormRow>
<Button type="submit" disabled={!file}>Upload file</Button>
</form>
</Card>
</div>
</Section>
);
}
function DiscussionsPanel({ group, threads, onDone }: { group: Group; threads: Thread[]; onDone: () => Promise<void> }) {
const [title, setTitle] = useState("");
async function createThread(event: FormEvent) {
event.preventDefault();
await postJson(`/api/groups/${group.id}/threads`, { title, kind: "discussion" });
setTitle("");
await onDone();
}
return (
<Section title="Discussions" eyebrow="Secondary">
<Card>
<form className="inline-form" onSubmit={createThread}>
<TextInput value={title} onChange={(event) => setTitle(event.target.value)} required placeholder="New discussion title" />
<Button type="submit">
<MessageSquare size={16} /> Start
</Button>
</form>
</Card>
<div className="list-panel">
{threads.map((thread) => (
<div className="list-row" key={thread.id}>
<div>
<strong>{thread.title}</strong>
<span>{thread.kind === "archive" ? "Read-only archive" : "Discussion thread"}</span>
</div>
<Badge tone={thread.kind === "archive" ? "quiet" : "neutral"}>{thread.kind}</Badge>
</div>
))}
</div>
</Section>
);
}
function AdminPanel({ group, onDone, setNotice }: { group: Group; onDone: () => Promise<void>; setNotice: (value: string) => void }) {
const [migration, setMigration] = useState<any | null>(null);
const [inviteUrl, setInviteUrl] = useState("");
const [copy, setCopy] = useState("");
async function loadMigration() {
setMigration(await api(`/api/groups/${group.id}/migration`));
}
useEffect(() => {
loadMigration();
}, [group.id]);
async function createInvite() {
const result = await postJson<{ invite_url: string }>(`/api/groups/${group.id}/invites`, { label: "Member invite", max_uses: 100 });
setInviteUrl(result.invite_url);
}
async function reminder() {
const result = await postJson<{ copy: string; invite_url: string }>(`/api/groups/${group.id}/migration/reminder-copy`, { frontend_origin: window.location.origin });
setCopy(result.copy);
setInviteUrl(result.invite_url);
await loadMigration();
}
async function markLegacy() {
await patchJson(`/api/groups/${group.id}`, { legacy_channel_status: "legacy" });
setNotice("Legacy channel status updated.");
await onDone();
}
return (
<Section title="Migration dashboard" eyebrow="Admin">
<div className="card-grid">
<Card>
<h3>Member migration</h3>
{migration ? (
<ul className="metric-list">
{Object.entries(migration.stats).map(([key, value]) => (
<li key={key}>
<strong>{String(value)}</strong>
<span>{key.replaceAll("_", " ")}</span>
</li>
))}
</ul>
) : (
<Loading label="Loading migration" />
)}
</Card>
<Card>
<h3>Invite and reminder</h3>
<div className="button-row">
<Button type="button" onClick={createInvite}>
<Plus size={16} /> Invite link
</Button>
<Button type="button" variant="secondary" onClick={reminder}>
<Clipboard size={16} /> Reminder copy
</Button>
<Button type="button" variant="ghost" onClick={markLegacy}>
Mark legacy
</Button>
</div>
{inviteUrl ? <pre className="copy-box">{inviteUrl}</pre> : null}
{copy ? <pre className="copy-box">{copy}</pre> : null}
</Card>
</div>
</Section>
);
}

View File

@@ -0,0 +1,89 @@
import { Plus } from "lucide-react";
import { FormEvent, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api, postJson } from "../api/client";
import type { Group, Member } from "../api/types";
import { Badge } from "../components/Badge";
import { Button } from "../components/Button";
import { Card } from "../components/Card";
import { FormRow, TextArea, TextInput } from "../components/FormRow";
import { Loading } from "../components/Loading";
import { Section } from "../components/Section";
type GroupRow = {
group: Group;
member: Member;
dashboard: { important_now: unknown[]; upcoming: unknown[]; files: unknown[] };
};
export function GroupsPage() {
const [groups, setGroups] = useState<GroupRow[] | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
async function load() {
const data = await api<{ groups: GroupRow[] }>("/api/groups");
setGroups(data.groups);
}
useEffect(() => {
load();
}, []);
async function createGroup(event: FormEvent) {
event.preventDefault();
await postJson("/api/groups", { name, description, visibility: "private", owner_display_name: "Group admin" });
setName("");
setDescription("");
await load();
}
if (!groups) return <Loading label="Loading groups" />;
return (
<div className="page-stack">
<header className="page-header">
<span className="eyebrow">Groups</span>
<h1>Structured group spaces</h1>
<p>Dashboards, members, files, announcements, tasks, and discussions grouped by organization.</p>
</header>
<Section title="My groups">
<div className="card-grid">
{groups.map(({ group, member, dashboard }) => (
<Link to={`/groups/${group.id}`} className="card link-card" key={group.id}>
<div className="card-topline">
<Badge tone="good">{member.role}</Badge>
<Badge tone="quiet">{group.legacy_channel_status}</Badge>
</div>
<h3>{group.name}</h3>
<p>{group.description}</p>
<ul className="mini-stats">
<li>{dashboard.important_now.length} open actions</li>
<li>{dashboard.upcoming.length} upcoming</li>
<li>{dashboard.files.length} files</li>
</ul>
</Link>
))}
</div>
</Section>
<Section title="Create a group" eyebrow="Admin">
<Card>
<form className="form-grid" onSubmit={createGroup}>
<FormRow label="Group name">
<TextInput value={name} onChange={(event) => setName(event.target.value)} required placeholder="Choir parents" />
</FormRow>
<FormRow label="Purpose">
<TextArea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="Announcements, events, files, and coordination." />
</FormRow>
<Button type="submit">
<Plus size={16} /> Create group
</Button>
</form>
</Card>
</Section>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { Check, ExternalLink } from "lucide-react";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api, postJson } from "../api/client";
import type { ActionItem, Announcement, EventItem } from "../api/types";
import { formatDateTime } from "../app/format";
import { Badge } from "../components/Badge";
import { Button } from "../components/Button";
import { Card } from "../components/Card";
import { EmptyState } from "../components/EmptyState";
import { Loading } from "../components/Loading";
import { Section } from "../components/Section";
type HomeResponse = {
profile: { primary_display_name: string };
sections: {
needs_me: ActionItem[];
today: EventItem[];
changed: ActionItem[];
official_updates: Announcement[];
catch_up: { label: string; count: number }[];
};
connections: { id: string; server_name: string; status: string }[];
};
export function HomePage() {
const [data, setData] = useState<HomeResponse | null>(null);
const [error, setError] = useState<string | null>(null);
async function load() {
try {
setData(await api<HomeResponse>("/api/home"));
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load home.");
}
}
useEffect(() => {
load();
}, []);
async function rsvp(action: ActionItem, status: "yes" | "maybe" | "no") {
await postJson(`/api/events/${action.object_id}/rsvp`, { status });
await load();
}
if (error) return <div className="error-panel">{error}</div>;
if (!data) return <Loading label="Loading home" />;
return (
<div className="page-stack">
<header className="page-header">
<div>
<span className="eyebrow">Home</span>
<h1>What needs attention</h1>
<p>{data.profile.primary_display_name}'s groups, actions, and official updates in one place.</p>
</div>
</header>
<Section title="Needs me" eyebrow="Actionable">
<div className="card-grid">
{data.sections.needs_me.length === 0 ? <EmptyState title="No open actions" body="You are caught up across your groups." /> : null}
{data.sections.needs_me.map((action) => (
<Card key={action.id}>
<div className="card-topline">
<Badge tone={action.priority === "urgent" ? "urgent" : action.source_type === "remote" ? "remote" : "neutral"}>{action.type.replaceAll("_", " ")}</Badge>
{action.source_type === "remote" ? <Badge tone="remote">Remote</Badge> : null}
</div>
<h3>{action.title}</h3>
<p>{action.summary}</p>
<div className="meta-row">
<span>{action.source_group_name}</span>
{action.due_at ? <span>{formatDateTime(action.due_at)}</span> : null}
</div>
{action.object_type === "event" && action.source_type !== "remote" ? (
<div className="button-row">
<Button type="button" onClick={() => rsvp(action, "yes")}>
<Check size={16} /> Yes
</Button>
<Button type="button" variant="secondary" onClick={() => rsvp(action, "maybe")}>
Maybe
</Button>
<Button type="button" variant="ghost" onClick={() => rsvp(action, "no")}>
No
</Button>
</div>
) : (
<Link className="text-link" to={action.source_type === "remote" ? "/me/servers/connect" : `/groups/${action.source_group_id}`}>
Open source <ExternalLink size={14} />
</Link>
)}
</Card>
))}
</div>
</Section>
<Section title="Today / Upcoming" eyebrow="Agenda">
<div className="list-panel">
{data.sections.today.map((event) => (
<Link key={`${event.source_type}-${event.id}`} to={event.source_type === "remote" ? "/me/servers/connect" : `/events/${event.id}`} className="list-row">
<div>
<strong>{event.title}</strong>
<span>{event.group_name}</span>
</div>
<div className="row-tail">
{event.changed_at ? <Badge tone="urgent">Changed</Badge> : null}
<span>{formatDateTime(event.starts_at)}</span>
</div>
</Link>
))}
</div>
</Section>
<div className="two-column">
<Section title="Official updates" eyebrow="Not chatter">
<div className="list-panel">
{data.sections.official_updates.map((item) => (
<Link key={item.id} to={`/announcements/${item.id}`} className="list-row">
<div>
<strong>{item.title}</strong>
<span>{item.group_name}</span>
</div>
<Badge tone={item.priority === "urgent" ? "urgent" : "good"}>{item.official ? "Official" : "Discussion"}</Badge>
</Link>
))}
</div>
</Section>
<Section title="Catch up" eyebrow="Since last visit">
<Card>
<p>While you were away:</p>
<ul className="metric-list">
{data.sections.catch_up.map((item) => (
<li key={item.label}>
<strong>{item.count}</strong>
<span>{item.label}</span>
</li>
))}
</ul>
</Card>
</Section>
</div>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { Check, Smartphone } from "lucide-react";
import { FormEvent, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { api, postJson } from "../api/client";
import type { Announcement, EventItem, Group } from "../api/types";
import { formatDateTime } from "../app/format";
import { Badge } from "../components/Badge";
import { Button } from "../components/Button";
import { Card } from "../components/Card";
import { FormRow, TextInput } from "../components/FormRow";
import { Loading } from "../components/Loading";
type Preview = {
group: Group;
invite: { label: string; role: string };
preview: { announcements: Announcement[]; events: EventItem[] };
};
export function JoinPage() {
const { token } = useParams();
const [preview, setPreview] = useState<Preview | null>(null);
const [displayName, setDisplayName] = useState("");
const [claimed, setClaimed] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token) return;
api<Preview>(`/api/join/${token}/preview`)
.then((data) => {
setPreview(data);
setDisplayName("");
})
.catch((err) => setError(err instanceof Error ? err.message : "This invite is not available."));
}, [token]);
async function claim(event: FormEvent) {
event.preventDefault();
if (!token) return;
await postJson(`/api/auth/invite/${token}/claim`, { display_name: displayName, device_label: "This browser" });
setClaimed(true);
}
async function rsvp(eventId: string, status: "yes" | "maybe" | "no") {
await postJson(`/api/events/${eventId}/rsvp`, { status });
setClaimed(true);
}
if (error) {
return (
<main className="join-shell">
<Card>
<h1>Invite unavailable</h1>
<p>{error}</p>
</Card>
</main>
);
}
if (!preview) return <Loading label="Opening invite" />;
return (
<main className="join-shell">
<section className="join-card">
<div className="brand-lockup">
<span className="brand-mark">GH</span>
<div>
<strong>GroupHome</strong>
<small>Open group</small>
</div>
</div>
<Badge tone="good">{preview.invite.role}</Badge>
<h1>{preview.group.name}</h1>
<p>{preview.group.description}</p>
<p className="pitch">Get updates, RSVP, files, and decisions here without reading every group chat.</p>
{!claimed ? (
<form className="form-grid" onSubmit={claim}>
<FormRow label="Display name">
<TextInput value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="Anna Müller" required autoFocus />
</FormRow>
<Button type="submit">
<Smartphone size={16} /> Join this group
</Button>
</form>
) : (
<div className="notice">
<Check size={17} /> You joined. You can act now and save access later.
</div>
)}
</section>
<section className="join-preview">
<h2>Official updates</h2>
<div className="card-grid">
{preview.preview.announcements.map((item) => (
<Card key={item.id}>
<Badge tone={item.priority === "urgent" ? "urgent" : "good"}>Official</Badge>
<h3>{item.title}</h3>
<p>{item.body}</p>
</Card>
))}
</div>
<h2>Upcoming</h2>
<div className="card-grid">
{preview.preview.events.map((event) => (
<Card key={event.id}>
<Badge tone="neutral">{formatDateTime(event.starts_at)}</Badge>
<h3>{event.title}</h3>
<p>{event.location_name || event.description}</p>
{claimed && event.rsvp_required ? (
<div className="button-row">
<Button type="button" onClick={() => rsvp(event.id, "yes")}>
Yes
</Button>
<Button type="button" variant="secondary" onClick={() => rsvp(event.id, "maybe")}>
Maybe
</Button>
<Button type="button" variant="ghost" onClick={() => rsvp(event.id, "no")}>
No
</Button>
</div>
) : null}
</Card>
))}
</div>
{claimed ? (
<Link className="button button-primary" to={`/groups/${preview.group.id}`}>
Open group dashboard
</Link>
) : null}
</section>
</main>
);
}

View File

@@ -0,0 +1,362 @@
import { Bell, KeyRound, Link2, RefreshCw, Server, ShieldCheck, Smartphone, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useState } from "react";
import { api, deleteJson, patchJson, postJson } from "../api/client";
import { Badge } from "../components/Badge";
import { Button } from "../components/Button";
import { Card } from "../components/Card";
import { FormRow, TextInput } from "../components/FormRow";
import { Loading } from "../components/Loading";
import { Section } from "../components/Section";
type MePageProps = {
initialPanel?: "profile" | "devices" | "notifications" | "servers";
};
export function MePage({ initialPanel = "profile" }: MePageProps) {
const [panel, setPanel] = useState(initialPanel);
const [me, setMe] = useState<any | null>(null);
const [devices, setDevices] = useState<any[]>([]);
const [prefs, setPrefs] = useState<Record<string, string>>({});
const [notifications, setNotifications] = useState<any[]>([]);
const [servers, setServers] = useState<any[]>([]);
const [notice, setNotice] = useState("");
async function load() {
const [meData, deviceData, prefData, notificationData, serverData] = await Promise.all([
api<any>("/api/me"),
api<any>("/api/me/devices"),
api<any>("/api/me/notification-preferences"),
api<any>("/api/me/notifications"),
api<any>("/api/remote/servers")
]);
setMe(meData);
setDevices(deviceData.devices);
setPrefs(prefData.preferences);
setNotifications(notificationData.notifications);
setServers(serverData.servers);
}
useEffect(() => {
setPanel(initialPanel);
}, [initialPanel]);
useEffect(() => {
load();
}, []);
if (!me) return <Loading label="Loading profile" />;
return (
<div className="page-stack">
<header className="page-header">
<span className="eyebrow">Me</span>
<h1>{me.profile?.primary_display_name ?? "Your access"}</h1>
<p>Save access, link devices, tune notifications, and connect group servers.</p>
</header>
<div className="segmented" role="tablist">
{[
["profile", "Profile"],
["devices", "Devices"],
["notifications", "Notifications"],
["servers", "Servers"]
].map(([key, label]) => (
<button key={key} type="button" className={panel === key ? "active" : ""} onClick={() => setPanel(key as typeof panel)}>
{label}
</button>
))}
</div>
{notice ? <div className="notice">{notice}</div> : null}
{panel === "profile" ? <ProfilePanel setNotice={setNotice} /> : null}
{panel === "devices" ? <DevicesPanel devices={devices} reload={load} setNotice={setNotice} /> : null}
{panel === "notifications" ? <NotificationsPanel prefs={prefs} notifications={notifications} reload={load} setNotice={setNotice} /> : null}
{panel === "servers" ? <ServersPanel servers={servers} reload={load} setNotice={setNotice} /> : null}
</div>
);
}
function ProfilePanel({ setNotice }: { setNotice: (value: string) => void }) {
const [email, setEmail] = useState("");
const [recoveryCode, setRecoveryCode] = useState("");
const [devCode, setDevCode] = useState("");
async function requestRecovery(event: FormEvent) {
event.preventDefault();
const result = await postJson<any>("/api/auth/recovery/request", { email });
setDevCode(result.dev_code ?? "");
setNotice("Recovery access prepared.");
}
async function consumeRecovery(event: FormEvent) {
event.preventDefault();
await postJson("/api/auth/recovery/consume", { recovery_code: recoveryCode, device_label: "Recovered browser" });
setNotice("Access recovered on this browser.");
}
async function passkeyReady() {
await postJson("/api/auth/passkeys/register/options", { display_name: "GroupHome member" });
await postJson("/api/auth/passkeys/register/verify", { development: true });
setNotice("Passkey-ready protection is wired for development.");
}
return (
<Section title="Save access" eyebrow="Progressive identity">
<div className="card-grid">
<Card>
<h3>
<ShieldCheck size={17} /> Recovery email
</h3>
<form className="form-grid" onSubmit={requestRecovery}>
<FormRow label="Email">
<TextInput type="email" value={email} onChange={(event) => setEmail(event.target.value)} required placeholder="anna@example.org" />
</FormRow>
<Button type="submit">Send recovery link</Button>
</form>
{devCode ? <pre className="copy-box">{devCode}</pre> : null}
</Card>
<Card>
<h3>
<KeyRound size={17} /> Recover access
</h3>
<form className="form-grid" onSubmit={consumeRecovery}>
<FormRow label="Recovery code">
<TextInput value={recoveryCode} onChange={(event) => setRecoveryCode(event.target.value)} required />
</FormRow>
<Button type="submit">Recover access</Button>
</form>
</Card>
<Card>
<h3>Protect access</h3>
<p>Development passkey routes are available behind a pluggable provider.</p>
<Button type="button" variant="secondary" onClick={passkeyReady}>
<KeyRound size={16} /> Make passkey-ready
</Button>
</Card>
</div>
</Section>
);
}
function DevicesPanel({ devices, reload, setNotice }: { devices: any[]; reload: () => Promise<void>; setNotice: (value: string) => void }) {
const [startedCode, setStartedCode] = useState("");
const [approveCode, setApproveCode] = useState("");
const [completeCode, setCompleteCode] = useState("");
async function start() {
const result = await postJson<any>("/api/auth/device-link/start", { device_label: "Second browser" });
setStartedCode(result.code);
setCompleteCode(result.code);
}
async function approve(event: FormEvent) {
event.preventDefault();
await postJson("/api/auth/device-link/approve", { code: approveCode || startedCode });
setNotice("Device link approved.");
}
async function complete(event: FormEvent) {
event.preventDefault();
await postJson("/api/auth/device-link/complete", { code: completeCode, device_label: "Linked browser" });
setNotice("Device linked.");
await reload();
}
async function revoke(id: string) {
await deleteJson(`/api/me/devices/${id}`);
await reload();
}
return (
<Section title="Devices" eyebrow="Link another device">
<div className="card-grid">
<Card>
<h3>
<Smartphone size={17} /> Pairing
</h3>
<div className="button-row">
<Button type="button" onClick={start}>
<Link2 size={16} /> Start code
</Button>
</div>
{startedCode ? <pre className="pairing-code">{startedCode}</pre> : null}
<form className="form-grid" onSubmit={approve}>
<FormRow label="Approve code">
<TextInput value={approveCode} onChange={(event) => setApproveCode(event.target.value)} placeholder={startedCode || "ABC123"} />
</FormRow>
<Button type="submit" variant="secondary">Approve</Button>
</form>
<form className="form-grid" onSubmit={complete}>
<FormRow label="Complete code">
<TextInput value={completeCode} onChange={(event) => setCompleteCode(event.target.value)} placeholder="Code from new device" />
</FormRow>
<Button type="submit" variant="secondary">Complete on this browser</Button>
</form>
</Card>
<Card>
<h3>Known devices</h3>
<div className="list-panel flush">
{devices.map((device) => (
<div className="list-row" key={device.id}>
<div>
<strong>{device.label}</strong>
<span>{device.trust_level}</span>
</div>
{device.current ? <Badge tone="good">Current</Badge> : null}
{!device.revoked_at ? (
<Button type="button" variant="ghost" onClick={() => revoke(device.id)} aria-label={`Revoke ${device.label}`}>
<Trash2 size={16} />
</Button>
) : (
<Badge tone="quiet">Revoked</Badge>
)}
</div>
))}
</div>
</Card>
</div>
</Section>
);
}
function NotificationsPanel({
prefs,
notifications,
reload,
setNotice
}: {
prefs: Record<string, string>;
notifications: any[];
reload: () => Promise<void>;
setNotice: (value: string) => void;
}) {
const [draft, setDraft] = useState(prefs);
useEffect(() => {
setDraft(prefs);
}, [prefs]);
async function save() {
await patchJson("/api/me/notification-preferences", { preferences: draft });
setNotice("Notification preferences saved.");
await reload();
}
async function markRead(id: string) {
await patchJson(`/api/me/notifications/${id}/read`, {});
await reload();
}
return (
<Section title="Notifications" eyebrow="Mute the noise, not the group">
<div className="card-grid">
<Card>
<h3>
<Bell size={17} /> Preferences
</h3>
<div className="form-grid">
{Object.entries(draft).map(([category, delivery]) => (
<FormRow key={category} label={category.replaceAll("_", " ")}>
<select className="input" value={delivery} onChange={(event) => setDraft({ ...draft, [category]: event.target.value })}>
<option value="immediate">Immediate</option>
<option value="digest">Digest</option>
<option value="muted">Muted</option>
</select>
</FormRow>
))}
<Button type="button" onClick={save}>Save preferences</Button>
</div>
</Card>
<Card>
<h3>Inbox</h3>
<div className="list-panel flush">
{notifications.map((item) => (
<div className="list-row" key={item.id}>
<div>
<strong>{item.title}</strong>
<span>{item.body}</span>
</div>
{item.read_at ? <Badge tone="quiet">Read</Badge> : <Button type="button" variant="ghost" onClick={() => markRead(item.id)}>Read</Button>}
</div>
))}
</div>
</Card>
</div>
</Section>
);
}
function ServersPanel({ servers, reload, setNotice }: { servers: any[]; reload: () => Promise<void>; setNotice: (value: string) => void }) {
const [serverUrl, setServerUrl] = useState("http://localhost:8000");
const [connectionCode, setConnectionCode] = useState("");
const [generatedCode, setGeneratedCode] = useState("");
async function createCode() {
const result = await postJson<any>("/api/connection-tokens", { label: "Connect my home server", scopes: ["sync:read"] });
setGeneratedCode(result.connection_code_display_once);
setConnectionCode(result.connection_code_display_once);
}
async function connect(event: FormEvent) {
event.preventDefault();
await postJson("/api/remote/servers/connect", { server_url: serverUrl, connection_code: connectionCode });
setNotice("Connected server synced.");
await reload();
}
async function sync(id: string) {
await postJson(`/api/remote/servers/${id}/sync`, {});
await reload();
}
async function remove(id: string) {
await deleteJson(`/api/remote/servers/${id}`);
await reload();
}
return (
<Section title="Connected servers" eyebrow="No full federation">
<div className="card-grid">
<Card>
<h3>
<Server size={17} /> Connect another group server
</h3>
<div className="button-row">
<Button type="button" variant="secondary" onClick={createCode}>
Create connection code
</Button>
</div>
{generatedCode ? <pre className="copy-box">{generatedCode}</pre> : null}
<form className="form-grid" onSubmit={connect}>
<FormRow label="Server URL">
<TextInput value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} required />
</FormRow>
<FormRow label="Connection code">
<TextInput value={connectionCode} onChange={(event) => setConnectionCode(event.target.value)} required />
</FormRow>
<Button type="submit">Connect and sync</Button>
</form>
</Card>
<Card>
<h3>Servers</h3>
<div className="list-panel flush">
{servers.map((server) => (
<div className="list-row" key={server.id}>
<div>
<strong>{server.server_name}</strong>
<span>{server.server_origin}</span>
</div>
<Badge tone={server.status === "active" ? "good" : "urgent"}>{server.status}</Badge>
<Button type="button" variant="ghost" onClick={() => sync(server.id)} aria-label={`Sync ${server.server_name}`}>
<RefreshCw size={16} />
</Button>
<Button type="button" variant="ghost" onClick={() => remove(server.id)} aria-label={`Remove ${server.server_name}`}>
<Trash2 size={16} />
</Button>
</div>
))}
</div>
</Card>
</div>
</Section>
);
}

View File

@@ -0,0 +1,907 @@
:root {
color-scheme: light;
--bg: #f7f4ee;
--surface: #fffdf8;
--surface-strong: #ffffff;
--ink: #20211f;
--muted: #65675f;
--line: #ded9cf;
--teal: #0f766e;
--teal-ink: #073b37;
--coral: #c4513d;
--amber: #8a5b00;
--green: #2f7d32;
--indigo: #3b4a8f;
--shadow: 0 12px 28px rgba(38, 31, 22, 0.1);
--radius: 8px;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
background: var(--bg);
color: var(--ink);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
textarea,
select {
font: inherit;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-size: 2rem;
line-height: 1.05;
}
h2 {
font-size: 1.2rem;
}
h3 {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 1rem;
line-height: 1.25;
}
p {
color: var(--muted);
line-height: 1.5;
}
.app-shell {
min-height: 100vh;
padding-bottom: 76px;
}
.main-panel {
width: min(1120px, 100%);
margin: 0 auto;
padding: 18px 14px 28px;
}
.side-nav {
display: none;
}
.brand-lockup {
display: flex;
align-items: center;
gap: 0.75rem;
}
.brand-lockup small {
display: block;
color: var(--muted);
}
.brand-mark {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: var(--radius);
background: var(--teal);
color: white;
font-weight: 800;
}
.bottom-nav {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(6, 1fr);
min-height: 68px;
border-top: 1px solid var(--line);
background: rgba(255, 253, 248, 0.96);
backdrop-filter: blur(10px);
}
.bottom-item,
.nav-item {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
color: var(--muted);
font-size: 0.75rem;
font-weight: 700;
}
.bottom-item {
flex-direction: column;
}
.bottom-item.active,
.nav-item.active {
color: var(--teal-ink);
}
.bottom-item.primary {
position: relative;
transform: translateY(-12px);
}
.bottom-item.primary svg {
width: 40px;
height: 40px;
padding: 0.55rem;
border-radius: 999px;
background: var(--teal);
color: #fff;
box-shadow: 0 10px 22px rgba(15, 118, 110, 0.28);
}
.bottom-item.primary span {
color: var(--teal-ink);
}
.nav-item.primary {
background: var(--teal);
color: white;
}
.nav-item.primary.active {
color: white;
}
.page-stack,
.standalone {
display: grid;
gap: 1.1rem;
}
.standalone {
min-height: 100vh;
place-items: center;
padding: 1rem;
}
.page-header {
display: grid;
gap: 0.5rem;
padding: 0.5rem 0 0.25rem;
}
.eyebrow {
color: var(--teal);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.section {
display: grid;
gap: 0.75rem;
}
.section-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
}
.card-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
.card {
display: grid;
gap: 0.75rem;
min-width: 0;
padding: 1rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-strong);
box-shadow: var(--shadow);
}
.link-card {
transition:
transform 0.15s ease,
border-color 0.15s ease;
}
.link-card:hover {
transform: translateY(-1px);
border-color: var(--teal);
}
.card-topline,
.meta-row,
.button-row,
.inline-meta,
.row-tail {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.meta-row,
.row-tail {
color: var(--muted);
font-size: 0.86rem;
}
.row-tail {
justify-content: flex-end;
}
.button-row.compact {
justify-content: flex-start;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
width: fit-content;
min-height: 24px;
padding: 0.15rem 0.5rem;
border-radius: 999px;
border: 1px solid var(--line);
background: #f4f0e8;
color: var(--muted);
font-size: 0.74rem;
font-weight: 800;
text-transform: capitalize;
}
.badge-urgent {
border-color: rgba(196, 81, 61, 0.35);
background: #fff0ed;
color: var(--coral);
}
.badge-good {
border-color: rgba(47, 125, 50, 0.28);
background: #edf7ee;
color: var(--green);
}
.badge-remote {
border-color: rgba(59, 74, 143, 0.28);
background: #eef1ff;
color: var(--indigo);
}
.badge-quiet {
background: #f6f1df;
color: var(--amber);
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
min-height: 42px;
padding: 0.6rem 0.85rem;
border: 1px solid transparent;
border-radius: var(--radius);
cursor: pointer;
font-weight: 800;
line-height: 1;
white-space: nowrap;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.button-primary {
background: var(--teal);
color: #fff;
}
.button-secondary {
border-color: rgba(15, 118, 110, 0.28);
background: #e7f4f2;
color: var(--teal-ink);
}
.button-ghost {
border-color: var(--line);
background: transparent;
color: var(--ink);
}
.button-danger {
background: var(--coral);
color: #fff;
}
.text-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--teal);
font-weight: 800;
}
.list-panel {
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-strong);
}
.list-panel.flush {
border: 0;
border-radius: 0;
}
.list-row {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.6rem;
min-width: 0;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--line);
}
.list-row:last-child {
border-bottom: 0;
}
.list-row > div:first-child {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.list-row strong,
.list-row span {
overflow-wrap: anywhere;
}
.list-row span {
color: var(--muted);
font-size: 0.88rem;
}
.timeline {
display: grid;
gap: 0.75rem;
}
.timeline-card {
grid-template-columns: 8.5rem minmax(0, 1fr);
}
.date-block {
color: var(--teal);
font-weight: 800;
}
.form-grid {
display: grid;
gap: 0.75rem;
}
.inline-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.6rem;
}
.form-row {
display: grid;
gap: 0.35rem;
font-weight: 750;
}
.form-row span {
font-size: 0.84rem;
}
.input {
width: 100%;
min-height: 42px;
padding: 0.6rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
}
.textarea {
min-height: 104px;
resize: vertical;
}
.segmented {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.25rem;
padding: 0.25rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.segmented button {
min-height: 38px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-weight: 800;
text-transform: capitalize;
}
.segmented button.active {
background: var(--teal);
color: white;
}
.segmented button:disabled {
color: #b8afa2;
cursor: not-allowed;
}
.two-column {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.metric-list,
.mini-stats {
display: grid;
gap: 0.45rem;
padding: 0;
margin: 0;
list-style: none;
}
.metric-list li,
.mini-stats li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
color: var(--muted);
}
.metric-list strong {
color: var(--ink);
font-size: 1.25rem;
}
.stack-tight {
display: grid;
gap: 0.5rem;
}
.copy-box,
.pairing-code {
overflow-x: auto;
max-width: 100%;
margin: 0;
padding: 0.75rem;
border: 1px dashed var(--teal);
border-radius: var(--radius);
background: #f0faf8;
color: var(--teal-ink);
font-weight: 800;
white-space: pre-wrap;
}
.pairing-code {
text-align: center;
font-size: 1.6rem;
}
.notice,
.error-panel,
.empty-state,
.loading {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.85rem 1rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-strong);
}
.error-panel {
display: grid;
max-width: 520px;
color: var(--coral);
}
.empty-state {
display: grid;
place-items: center;
min-height: 140px;
color: var(--muted);
text-align: center;
}
.loading {
width: fit-content;
margin: 2rem auto;
}
.skeleton-dot {
width: 12px;
height: 12px;
border-radius: 999px;
background: var(--teal);
animation: pulse 1s infinite ease-in-out;
}
@keyframes pulse {
50% {
opacity: 0.35;
}
}
.join-shell {
display: grid;
gap: 1rem;
width: min(1040px, 100%);
min-height: 100vh;
margin: 0 auto;
padding: 1rem;
}
.join-card,
.join-preview {
display: grid;
gap: 1rem;
align-content: start;
padding: 1.1rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-strong);
box-shadow: var(--shadow);
}
.pitch {
color: var(--teal-ink);
font-weight: 750;
}
.chat-page {
display: grid;
gap: 0.85rem;
min-height: calc(100vh - 116px);
}
.chat-sidebar,
.chat-panel {
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-strong);
box-shadow: var(--shadow);
}
.chat-sidebar {
display: grid;
gap: 0.8rem;
align-content: start;
padding: 0.85rem;
}
.chat-sidebar-header,
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.chat-sidebar h1 {
font-size: 1.45rem;
}
.chat-thread-list {
display: grid;
gap: 0.35rem;
max-height: 28vh;
overflow: auto;
}
.chat-thread {
display: grid;
gap: 0.2rem;
width: 100%;
min-height: 72px;
padding: 0.75rem;
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--ink);
cursor: pointer;
text-align: left;
}
.chat-thread.active {
border-color: rgba(15, 118, 110, 0.28);
background: #e7f4f2;
}
.chat-thread span,
.chat-thread small {
overflow: hidden;
color: var(--muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-panel {
display: grid;
grid-template-rows: auto minmax(320px, 1fr) auto;
overflow: hidden;
}
.chat-header {
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--line);
}
.chat-header h2 {
font-size: 1.05rem;
}
.toggle-row {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--muted);
font-size: 0.86rem;
font-weight: 800;
}
.message-list {
display: flex;
flex-direction: column;
gap: 0.7rem;
min-height: 0;
padding: 1rem;
overflow-y: auto;
background:
linear-gradient(rgba(247, 244, 238, 0.86), rgba(247, 244, 238, 0.86)),
repeating-linear-gradient(45deg, rgba(15, 118, 110, 0.04) 0 2px, transparent 2px 14px);
}
.message-row {
display: flex;
justify-content: flex-start;
}
.message-row.mine {
justify-content: flex-end;
}
.bubble {
display: grid;
gap: 0.35rem;
max-width: min(78%, 620px);
padding: 0.7rem 0.8rem;
border: 1px solid var(--line);
border-radius: 14px 14px 14px 4px;
background: white;
box-shadow: 0 8px 18px rgba(38, 31, 22, 0.07);
}
.bubble.mine {
border-color: rgba(15, 118, 110, 0.22);
border-radius: 14px 14px 4px 14px;
background: #dff3ef;
}
.bubble p {
color: var(--ink);
overflow-wrap: anywhere;
}
.bubble-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.8rem;
color: var(--muted);
font-size: 0.78rem;
}
.bubble-meta strong {
color: var(--teal-ink);
}
.folded-noise {
align-self: center;
display: inline-flex;
align-items: center;
gap: 0.35rem;
max-width: 90%;
padding: 0.35rem 0.65rem;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 253, 248, 0.88);
color: var(--muted);
font-size: 0.82rem;
font-weight: 750;
}
.chat-composer {
display: grid;
gap: 0.55rem;
padding: 0.75rem;
border-top: 1px solid var(--line);
background: var(--surface-strong);
}
.structure-strip {
display: flex;
gap: 0.4rem;
overflow-x: auto;
padding-bottom: 0.1rem;
}
.structure-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 34px;
padding: 0.35rem 0.6rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--muted);
cursor: pointer;
font-weight: 800;
white-space: nowrap;
}
.structure-chip.active {
border-color: rgba(15, 118, 110, 0.35);
background: #e7f4f2;
color: var(--teal-ink);
}
.composer-hint {
width: fit-content;
padding: 0.35rem 0.55rem;
border-radius: var(--radius);
background: #f6f1df;
color: var(--amber);
font-size: 0.83rem;
font-weight: 800;
}
.composer-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.55rem;
align-items: end;
}
.chat-input {
min-height: 54px;
max-height: 160px;
resize: vertical;
}
@media (min-width: 680px) {
.card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.list-row {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
.join-shell {
grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr);
align-items: start;
padding: 2rem;
}
}
@media (min-width: 960px) {
h1 {
font-size: 2.4rem;
}
.app-shell {
display: grid;
grid-template-columns: 236px minmax(0, 1fr);
padding-bottom: 0;
}
.side-nav {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
height: 100vh;
padding: 1.25rem;
border-right: 1px solid var(--line);
background: var(--surface);
}
.side-nav nav {
display: grid;
gap: 0.3rem;
}
.nav-item {
justify-content: flex-start;
min-height: 42px;
padding: 0 0.75rem;
border-radius: var(--radius);
font-size: 0.92rem;
}
.nav-item.active {
background: #e7f4f2;
}
.bottom-nav {
display: none;
}
.main-panel {
margin: 0;
padding: 2rem;
}
.chat-page {
grid-template-columns: 320px minmax(0, 1fr);
min-height: calc(100vh - 4rem);
}
.chat-thread-list {
max-height: none;
}
.chat-panel {
grid-template-rows: auto minmax(0, 1fr) auto;
}
.card-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.two-column {
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr);
}
}

View File

@@ -0,0 +1,30 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { App } from "../app/App";
describe("App", () => {
it("renders the shell after dev bootstrap", async () => {
vi.stubGlobal(
"fetch",
vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ authenticated: true, dev_mode: true }), { status: 200 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
profile: { primary_display_name: "Anna Müller" },
sections: { needs_me: [], today: [], changed: [], official_updates: [], catch_up: [] },
connections: []
}),
{ status: 200 }
)
)
);
render(
<MemoryRouter initialEntries={["/"]}>
<App />
</MemoryRouter>
);
expect(await screen.findByText("What needs attention")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,2 @@
import "@testing-library/jest-dom/vitest";

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2021"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

18
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8000",
"/.well-known": "http://localhost:8000"
}
},
test: {
environment: "jsdom",
setupFiles: "./src/test/setup.ts"
}
});